Enhance interview functionality and settings; add YAML dependency
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run

- Introduced profile selection modal for creating notes from interview profiles.
- Added settings for interview configuration path, auto-starting interviews, and intercepting unresolved link clicks.
- Updated package files to include YAML dependency for configuration handling.
- Enhanced CSS for profile selection and interview wizard UI elements.
This commit is contained in:
Lars 2026-01-16 12:27:44 +01:00
parent d577283af6
commit bab84549e2
19 changed files with 2840 additions and 5 deletions

4
package-lock.json generated
View File

@ -14,7 +14,8 @@
"version": "1.0.0",
"license": "0-BSD",
"dependencies": {
"obsidian": "latest"
"obsidian": "latest",
"yaml": "^2.8.2"
},
"devDependencies": {
"@eslint/js": "9.30.1",
@ -6669,7 +6670,6 @@
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"

View File

@ -16,18 +16,19 @@
"keywords": [],
"license": "0-BSD",
"devDependencies": {
"@eslint/js": "9.30.1",
"@types/node": "^16.11.6",
"esbuild": "0.25.5",
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
"jiti": "2.6.1",
"tslib": "2.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1",
"@eslint/js": "9.30.1",
"jiti": "2.6.1",
"vitest": "^1.6.0"
},
"dependencies": {
"obsidian": "latest"
"obsidian": "latest",
"yaml": "^2.8.2"
}
}

View File

@ -0,0 +1,20 @@
import type { App } from "obsidian";
import { VocabularyLoader } from "../vocab/VocabularyLoader";
import { parseInterviewConfig, validateConfig } from "./parseInterviewConfig";
import type { InterviewConfig } from "./types";
/**
* Loader for interview config YAML files.
*/
export class InterviewConfigLoader {
/**
* Load and parse interview config from vault.
*/
static async loadConfig(
app: App,
path: string
): Promise<{ config: InterviewConfig; errors: string[] }> {
const yamlText = await VocabularyLoader.loadText(app, path);
return parseInterviewConfig(yamlText);
}
}

View File

@ -0,0 +1,52 @@
/**
* Extract basename from link target, preserving spaces and case.
* Only removes alias separator (|) and heading separator (#).
* For [[file#sec|alias]] => "file"
* For [[My Note]] => "My Note"
*/
export function extractBasenameFromTarget(target: string): string {
// Split by pipe (alias separator) and take first part
const parts = target.split("|");
const firstPart = parts[0];
if (!firstPart) return target.trim();
// Split by hash (heading separator) and take first part
const baseParts = firstPart.split("#");
const base = baseParts[0];
if (!base) return target.trim();
return base.trim();
}
/**
* Extract target basename from anchor data.
* Pure function that works with strings.
* Preserves spaces and case (Obsidian-style filenames).
*/
export function extractTargetFromData(
dataHref: string | null,
textContent: string | null
): string | null {
// Try data-href first
if (dataHref) {
return extractBasenameFromTarget(dataHref);
}
// Fall back to text content
if (textContent) {
return extractBasenameFromTarget(textContent.trim());
}
return null;
}
/**
* Extract target basename from an anchor element.
* Handles data-href attribute and text content.
* Preserves spaces and case (Obsidian-style filenames).
*/
export function extractTargetFromAnchor(anchor: HTMLElement): string | null {
const dataHref = anchor.getAttribute("data-href");
const textContent = anchor.textContent;
return extractTargetFromData(dataHref, textContent);
}

View File

@ -0,0 +1,363 @@
import { parse } from "yaml";
import type {
InterviewConfig,
InterviewProfile,
InterviewStep,
} from "./types";
export interface ParseResult {
config: InterviewConfig;
errors: string[];
}
/**
* Parse YAML interview config file.
*/
export function parseInterviewConfig(yamlText: string): ParseResult {
const errors: string[] = [];
try {
const raw = parse(yamlText) as unknown;
if (!raw || typeof raw !== "object") {
return {
config: {
version: "2.0",
frontmatterWhitelist: [],
profiles: [],
},
errors: ["Invalid YAML: root must be an object"],
};
}
const obj = raw as Record<string, unknown>;
// Extract version
const version = typeof obj.version === "string" ? obj.version : "2.0";
// Extract frontmatterWhitelist (accept both camelCase and snake_case)
const frontmatterWhitelist: string[] = [];
const whitelistSource = obj.frontmatterWhitelist || obj.frontmatter_whitelist;
if (Array.isArray(whitelistSource)) {
for (const item of whitelistSource) {
if (typeof item === "string") {
frontmatterWhitelist.push(item);
}
}
}
// Extract profiles
const profiles: InterviewProfile[] = [];
if (Array.isArray(obj.profiles)) {
for (let i = 0; i < obj.profiles.length; i++) {
const profileRaw = obj.profiles[i];
if (!profileRaw || typeof profileRaw !== "object") {
errors.push(`Profile at index ${i} is not an object`);
continue;
}
const profile = parseProfile(profileRaw as Record<string, unknown>, i);
if (profile) {
profiles.push(profile);
} else {
errors.push(`Failed to parse profile at index ${i}`);
}
}
}
const config: InterviewConfig = {
version,
frontmatterWhitelist,
profiles,
};
// Validate
const validationErrors = validateConfig(config);
errors.push(...validationErrors);
return { config, errors };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
config: {
version: "2.0",
frontmatterWhitelist: [],
profiles: [],
},
errors: [`YAML parse error: ${msg}`],
};
}
}
function parseProfile(
raw: Record<string, unknown>,
index: number
): InterviewProfile | null {
if (typeof raw.key !== "string" || !raw.key.trim()) {
return null;
}
if (typeof raw.label !== "string" || !raw.label.trim()) {
return null;
}
if (typeof raw.note_type !== "string" || !raw.note_type.trim()) {
return null;
}
const profile: InterviewProfile = {
key: raw.key.trim(),
label: raw.label.trim(),
note_type: raw.note_type.trim(),
steps: [],
};
if (typeof raw.group === "string" && raw.group.trim()) {
profile.group = raw.group.trim();
}
if (raw.defaults && typeof raw.defaults === "object") {
profile.defaults = raw.defaults as Record<string, unknown>;
}
// Parse steps
if (Array.isArray(raw.steps)) {
for (let i = 0; i < raw.steps.length; i++) {
const stepRaw = raw.steps[i];
if (!stepRaw || typeof stepRaw !== "object") {
continue;
}
const step = parseStep(stepRaw as Record<string, unknown>);
if (step) {
profile.steps.push(step);
}
}
}
return profile;
}
function parseStep(raw: Record<string, unknown>): InterviewStep | null {
// Accept both "type" and "kind" (YAML uses "kind", but we normalize to "type" internally)
const stepType = raw.type || raw.kind;
if (typeof stepType !== "string") {
return null;
}
const type = stepType;
// Helper: get key from "key" or "id" field
const getKey = (): string | null => {
if (typeof raw.key === "string" && raw.key.trim()) {
return raw.key.trim();
}
if (typeof raw.id === "string" && raw.id.trim()) {
return raw.id.trim();
}
return null;
};
if (type === "loop") {
const key = getKey();
if (!key) {
return null;
}
// Accept both "items" and "steps" for loop nested steps
const nestedSteps = raw.items || raw.steps;
if (!Array.isArray(nestedSteps)) {
return null;
}
const step: InterviewStep = {
type: "loop",
key: key,
items: [],
};
if (typeof raw.label === "string" && raw.label.trim()) {
step.label = raw.label.trim();
}
for (const itemRaw of nestedSteps) {
if (!itemRaw || typeof itemRaw !== "object") {
continue;
}
const item = parseStep(itemRaw as Record<string, unknown>);
if (item) {
step.items.push(item);
}
}
return step;
}
if (type === "llm_dialog") {
const key = getKey();
if (!key) {
return null;
}
// Accept both "prompt" and "prompt_template"
const promptText = raw.prompt || raw.prompt_template;
if (typeof promptText !== "string" || !promptText.trim()) {
return null;
}
const step: InterviewStep = {
type: "llm_dialog",
key: key,
prompt: promptText.trim(),
};
if (typeof raw.label === "string" && raw.label.trim()) {
step.label = raw.label.trim();
}
if (typeof raw.system_prompt === "string" && raw.system_prompt.trim()) {
step.system_prompt = raw.system_prompt.trim();
}
if (typeof raw.model === "string" && raw.model.trim()) {
step.model = raw.model.trim();
}
if (typeof raw.temperature === "number") {
step.temperature = raw.temperature;
}
if (typeof raw.max_tokens === "number") {
step.max_tokens = raw.max_tokens;
}
return step;
}
if (type === "capture_frontmatter") {
const key = getKey();
if (!key) {
return null;
}
if (typeof raw.field !== "string" || !raw.field.trim()) {
return null;
}
const step: InterviewStep = {
type: "capture_frontmatter",
key: key,
field: raw.field.trim(),
};
if (typeof raw.label === "string" && raw.label.trim()) {
step.label = raw.label.trim();
}
if (typeof raw.required === "boolean") {
step.required = raw.required;
}
return step;
}
if (type === "capture_text") {
const key = getKey();
if (!key) {
return null;
}
const step: InterviewStep = {
type: "capture_text",
key: key,
};
if (typeof raw.label === "string" && raw.label.trim()) {
step.label = raw.label.trim();
}
if (typeof raw.required === "boolean") {
step.required = raw.required;
}
if (typeof raw.section === "string" && raw.section.trim()) {
step.section = raw.section.trim();
}
if (typeof raw.prompt === "string" && raw.prompt.trim()) {
step.prompt = raw.prompt.trim();
}
return step;
}
if (type === "instruction") {
const key = getKey();
if (!key) {
return null;
}
if (typeof raw.content !== "string") {
return null;
}
const step: InterviewStep = {
type: "instruction",
key: key,
content: raw.content,
};
if (typeof raw.label === "string" && raw.label.trim()) {
step.label = raw.label.trim();
}
return step;
}
if (type === "review") {
const key = getKey();
if (!key) {
return null;
}
const step: InterviewStep = {
type: "review",
key: key,
};
if (typeof raw.label === "string" && raw.label.trim()) {
step.label = raw.label.trim();
}
return step;
}
return null;
}
/**
* Validate parsed config.
*/
export function validateConfig(config: InterviewConfig): string[] {
const errors: string[] = [];
// Check unique profile keys
const profileKeys = new Set<string>();
for (const profile of config.profiles) {
if (profileKeys.has(profile.key)) {
errors.push(`Duplicate profile key: ${profile.key}`);
} else {
profileKeys.add(profile.key);
}
}
// Check capture_frontmatter.field in whitelist
for (const profile of config.profiles) {
validateSteps(profile.steps, config.frontmatterWhitelist, errors);
}
return errors;
}
function validateSteps(
steps: InterviewStep[],
whitelist: string[],
errors: string[]
): void {
for (const step of steps) {
if (step.type === "capture_frontmatter") {
if (!whitelist.includes(step.field)) {
errors.push(
`capture_frontmatter field '${step.field}' not in frontmatterWhitelist`
);
}
} else if (step.type === "loop") {
validateSteps(step.items, whitelist, errors);
}
}
}

15
src/interview/slugify.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* Convert string to URL-safe slug (a-z0-9_).
* Used for file names.
*/
export function slugify(text: string): string {
if (!text) return "";
return text
.toLowerCase()
.normalize("NFD") // Decompose accented characters
.replace(/[\u0300-\u036f]/g, "") // Remove diacritics
.replace(/[^a-z0-9_]+/g, "_") // Replace non-alphanumeric with underscore
.replace(/^_+|_+$/g, "") // Remove leading/trailing underscores
.replace(/_+/g, "_"); // Collapse multiple underscores
}

74
src/interview/types.ts Normal file
View File

@ -0,0 +1,74 @@
/**
* InterviewConfig v2 types
*/
export interface InterviewConfig {
version: string;
frontmatterWhitelist: string[];
profiles: InterviewProfile[];
}
export interface InterviewProfile {
key: string;
label: string;
note_type: string;
group?: string;
defaults?: Record<string, unknown>;
steps: InterviewStep[];
}
export type InterviewStep =
| LoopStep
| LLMDialogStep
| CaptureFrontmatterStep
| CaptureTextStep
| InstructionStep
| ReviewStep;
export interface LoopStep {
type: "loop";
key: string;
label?: string;
items: InterviewStep[];
}
export interface LLMDialogStep {
type: "llm_dialog";
key: string;
label?: string;
prompt: string;
system_prompt?: string;
model?: string;
temperature?: number;
max_tokens?: number;
}
export interface CaptureFrontmatterStep {
type: "capture_frontmatter";
key: string;
label?: string;
field: string;
required?: boolean;
}
export interface CaptureTextStep {
type: "capture_text";
key: string;
label?: string;
required?: boolean;
section?: string; // Markdown section header (e.g., "## 🧩 Erlebnisse")
prompt?: string; // Optional prompt text
}
export interface InstructionStep {
type: "instruction";
key: string;
label?: string;
content: string;
}
export interface ReviewStep {
type: "review";
key: string;
label?: string;
}

View File

@ -0,0 +1,106 @@
import type { InterviewProfile, InterviewStep } from "./types";
export interface WizardState {
profile: InterviewProfile;
currentStepIndex: number;
stepHistory: number[]; // Stack for back navigation
collectedData: Map<string, unknown>; // key -> value
loopContexts: Map<string, unknown[]>; // loop key -> array of collected items
patches: Patch[]; // Collected patches to apply
}
export interface Patch {
type: "frontmatter" | "content";
field?: string; // For frontmatter patches
value: unknown;
lineStart?: number;
lineEnd?: number;
}
export function createWizardState(profile: InterviewProfile): WizardState {
// Log flattened steps before creating state
const flat = flattenSteps(profile.steps);
const flatKinds = flat.map(s => s.type);
console.log("Wizard flattened steps", {
count: flat.length,
kinds: flatKinds,
});
return {
profile,
currentStepIndex: 0,
stepHistory: [],
collectedData: new Map(),
loopContexts: new Map(),
patches: [],
};
}
export function getCurrentStep(state: WizardState): InterviewStep | null {
const steps = flattenSteps(state.profile.steps);
// Log flattened steps count
if (state.currentStepIndex === 0) {
console.log("Flattened steps", { count: steps.length });
}
if (steps.length === 0) {
console.warn("No steps available in profile", { profileKey: state.profile.key });
return null;
}
if (state.currentStepIndex >= 0 && state.currentStepIndex < steps.length) {
return steps[state.currentStepIndex] || null;
}
return null;
}
export function getNextStepIndex(state: WizardState): number | null {
const steps = flattenSteps(state.profile.steps);
if (state.currentStepIndex < steps.length - 1) {
return state.currentStepIndex + 1;
}
return null;
}
export function getPreviousStepIndex(state: WizardState): number | null {
if (state.stepHistory.length > 0) {
const prev = state.stepHistory[state.stepHistory.length - 1];
return prev !== undefined ? prev : null;
}
return null;
}
export function canGoNext(state: WizardState): boolean {
return getNextStepIndex(state) !== null;
}
export function canGoBack(state: WizardState): boolean {
return state.stepHistory.length > 0;
}
/**
* Flatten steps including loops into a linear array.
* Returns ALL top-level steps (instruction, capture_text, capture_frontmatter, llm_dialog, review, loop).
* For loops, includes the loop step itself (nested items are handled during execution).
* Exported for use in InterviewWizardModal.
*/
export function flattenSteps(steps: InterviewStep[]): InterviewStep[] {
const result: InterviewStep[] = [];
if (!steps || steps.length === 0) {
return result;
}
for (const step of steps) {
if (!step) {
console.warn("Skipping null/undefined step");
continue;
}
// Include all step types: instruction, capture_text, capture_frontmatter, llm_dialog, review, loop
// For loops, include the loop step itself (nested items handled during execution)
result.push(step);
}
return result;
}

View File

@ -0,0 +1,93 @@
import type { InterviewProfile } from "./types";
export interface FrontmatterOptions {
id: string;
title: string;
noteType: string;
interviewProfile: string;
defaults?: Record<string, unknown>;
frontmatterWhitelist: string[];
}
/**
* Write frontmatter YAML from options.
* Only includes defaults fields that are in frontmatterWhitelist.
*/
export function writeFrontmatter(opts: FrontmatterOptions): string {
const { id, title, noteType, interviewProfile, defaults, frontmatterWhitelist } = opts;
const lines: string[] = [];
lines.push("---");
// Required fields
lines.push(`id: ${escapeYamlValue(id)}`);
lines.push(`title: ${escapeYamlValue(title)}`);
lines.push(`type: ${escapeYamlValue(noteType)}`);
lines.push(`interview_profile: ${escapeYamlValue(interviewProfile)}`);
// Whitelisted defaults
if (defaults) {
for (const [key, value] of Object.entries(defaults)) {
if (frontmatterWhitelist.includes(key)) {
lines.push(`${key}: ${escapeYamlValue(value)}`);
}
}
}
lines.push("---");
return lines.join("\n");
}
/**
* Escape YAML value for frontmatter.
* Handles strings, numbers, booleans, null.
*/
function escapeYamlValue(value: unknown): string {
if (value === null || value === undefined) {
return "null";
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
if (typeof value === "number") {
return String(value);
}
if (typeof value === "string") {
// If string contains special characters, quote it
if (
value.includes(":") ||
value.includes("#") ||
value.includes("|") ||
value.includes(">") ||
value.includes("<") ||
value.includes("&") ||
value.includes("*") ||
value.includes("!") ||
value.includes("%") ||
value.includes("@") ||
value.includes("`") ||
value.includes('"') ||
value.includes("'") ||
value.includes("\n") ||
value.trim() !== value ||
value.startsWith("[") ||
value.startsWith("{")
) {
// Escape quotes and use double quotes
const escaped = value.replace(/"/g, '\\"');
return `"${escaped}"`;
}
return value;
}
// For objects/arrays, serialize as JSON string (quoted)
try {
const json = JSON.stringify(value);
return `"${json.replace(/"/g, '\\"')}"`;
} catch {
return '""';
}
}

View File

@ -11,11 +11,20 @@ import { buildIndex } from "./graph/GraphIndex";
import { traverseForward, traverseBackward, type Path } from "./graph/traverse";
import { renderChainReport } from "./graph/renderChainReport";
import { extractFrontmatterId } from "./parser/parseFrontmatter";
import { InterviewConfigLoader } from "./interview/InterviewConfigLoader";
import type { InterviewConfig } from "./interview/types";
import { ProfileSelectionModal } from "./ui/ProfileSelectionModal";
import { slugify } from "./interview/slugify";
import { writeFrontmatter } from "./interview/writeFrontmatter";
import { InterviewWizardModal, type WizardResult } from "./ui/InterviewWizardModal";
import { extractTargetFromAnchor } from "./interview/extractTargetFromAnchor";
export default class MindnetCausalAssistantPlugin extends Plugin {
settings: MindnetSettings;
private vocabulary: Vocabulary | null = null;
private reloadDebounceTimer: number | null = null;
private interviewConfig: InterviewConfig | null = null;
private interviewConfigReloadDebounceTimer: number | null = null;
async onload(): Promise<void> {
await this.loadSettings();
@ -23,6 +32,63 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
// Add settings tab
this.addSettingTab(new MindnetSettingTab(this.app, this));
// Register click handler for unresolved links
this.registerDomEvent(document, "click", async (evt: MouseEvent) => {
if (!this.settings.interceptUnresolvedLinkClicks) {
return;
}
const target = evt.target as HTMLElement;
if (!target) return;
// Find closest unresolved internal link
const anchor = target.closest("a.internal-link.is-unresolved");
if (!anchor || !(anchor instanceof HTMLElement)) {
return;
}
// Prevent default link behavior
evt.preventDefault();
evt.stopPropagation();
// Extract target basename (preserves spaces and case)
const basename = extractTargetFromAnchor(anchor);
if (!basename) {
return;
}
// Use basename directly as title (Option 1: exact match)
const title = basename;
// Load interview config and open profile selection
try {
const config = await this.ensureInterviewConfigLoaded();
if (!config) {
new Notice("Interview config not available");
return;
}
if (config.profiles.length === 0) {
new Notice("No profiles available in interview config");
return;
}
// Open profile selection modal
new ProfileSelectionModal(
this.app,
config,
async (result) => {
await this.createNoteFromProfileAndOpen(result, basename);
},
title
).open();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to handle link click: ${msg}`);
console.error(e);
}
});
// Register live reload for edge vocabulary file
this.registerEvent(
this.app.vault.on("modify", async (file: TFile) => {
@ -43,6 +109,22 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
this.reloadDebounceTimer = null;
}, 200);
}
// Check if modified file matches interview config path
const normalizedInterviewConfigPath = normalizeVaultPath(this.settings.interviewConfigPath);
if (normalizedFilePath === normalizedInterviewConfigPath ||
normalizedFilePath === `/${normalizedInterviewConfigPath}` ||
normalizedFilePath.endsWith(`/${normalizedInterviewConfigPath}`)) {
// Debounce reload
if (this.interviewConfigReloadDebounceTimer !== null) {
window.clearTimeout(this.interviewConfigReloadDebounceTimer);
}
this.interviewConfigReloadDebounceTimer = window.setTimeout(async () => {
await this.reloadInterviewConfig();
this.interviewConfigReloadDebounceTimer = null;
}, 200);
}
})
);
@ -223,6 +305,154 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
}
},
});
this.addCommand({
id: "mindnet-create-note-from-profile",
name: "Mindnet: Create note from profile",
callback: async () => {
try {
const config = await this.ensureInterviewConfigLoaded();
if (!config) {
return;
}
if (config.profiles.length === 0) {
new Notice("No profiles available in interview config");
return;
}
// Get link text from clipboard or active selection if available
let initialTitle = "";
try {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile) {
const content = await this.app.vault.read(activeFile);
const selection = this.app.workspace.activeEditor?.editor?.getSelection();
if (selection && selection.trim()) {
initialTitle = selection.trim();
}
}
} catch {
// Ignore errors getting initial title
}
// Show modal
new ProfileSelectionModal(
this.app,
config,
async (result) => {
await this.createNoteFromProfile(result);
},
initialTitle
).open();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to create note: ${msg}`);
console.error(e);
}
},
});
}
private async createNoteFromProfileAndOpen(
result: { profile: import("./interview/types").InterviewProfile; title: string },
preferredBasename?: string
): Promise<void> {
try {
const config = await this.ensureInterviewConfigLoaded();
if (!config) {
return;
}
// Use preferred basename if provided, otherwise use title directly (preserve spaces)
// Only use slugify if explicitly requested (future feature)
const filenameBase = preferredBasename || result.title;
if (!filenameBase || !filenameBase.trim()) {
new Notice("Invalid title: cannot create filename");
return;
}
// Generate unique ID (simple timestamp-based for now)
const id = `note_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
// Write frontmatter
const frontmatter = writeFrontmatter({
id,
title: result.title,
noteType: result.profile.note_type,
interviewProfile: result.profile.key,
defaults: result.profile.defaults,
frontmatterWhitelist: config.frontmatterWhitelist,
});
// Create file content
const content = `${frontmatter}\n\n`;
// Determine file path (use filenameBase directly, preserving spaces)
// Handle name conflicts by appending " (2)", " (3)", etc. (Obsidian style)
let fileName = `${filenameBase.trim()}.md`;
let filePath = fileName;
let attempt = 1;
while (true) {
const existingFile = this.app.vault.getAbstractFileByPath(filePath);
if (!existingFile) {
break; // File doesn't exist, we can use this path
}
attempt++;
fileName = `${filenameBase.trim()} (${attempt}).md`;
filePath = fileName;
}
// Create file
const file = await this.app.vault.create(filePath, content);
// Open file
await this.app.workspace.openLinkText(filePath, "", true);
// Show notice
new Notice(`Note created: ${result.title}`);
// Auto-start interview if setting enabled
if (this.settings.autoStartInterviewOnCreate) {
try {
console.log("Start wizard", {
profileKey: result.profile.key,
file: file.path,
});
new InterviewWizardModal(
this.app,
result.profile,
file,
content,
async (wizardResult: WizardResult) => {
new Notice("Interview completed and changes applied");
},
async (wizardResult: WizardResult) => {
new Notice("Interview saved and changes applied");
}
).open();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to start interview wizard: ${msg}`);
console.error("Failed to start wizard:", e);
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to create note: ${msg}`);
console.error(e);
}
}
private async createNoteFromProfile(
result: { profile: import("./interview/types").InterviewProfile; title: string }
): Promise<void> {
// For manual creation, use title directly (preserve spaces, Obsidian style)
// slugify() is kept for future "normalize filename" option
return this.createNoteFromProfileAndOpen(result, result.title);
}
onunload(): void {
@ -313,4 +543,75 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
console.error(e);
}
}
/**
* Ensure interview config is loaded. Auto-loads if not present.
* Returns InterviewConfig instance or null on failure.
*/
private async ensureInterviewConfigLoaded(): Promise<InterviewConfig | null> {
if (this.interviewConfig) {
return this.interviewConfig;
}
try {
const result = await InterviewConfigLoader.loadConfig(
this.app,
this.settings.interviewConfigPath
);
if (result.errors.length > 0) {
console.warn("Interview config loaded with errors:", result.errors);
new Notice(`Interview config loaded with ${result.errors.length} error(s). Check console.`);
}
this.interviewConfig = result.config;
console.log("Interview config auto-loaded", {
version: result.config.version,
profileCount: result.config.profiles.length,
});
return this.interviewConfig;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("not found") || msg.includes("not found in vault")) {
new Notice("interview_config.yaml not found. Check the path in plugin settings.");
} else {
new Notice(`Failed to load interview config: ${msg}. Check plugin settings.`);
}
console.error("Failed to load interview config:", e);
return null;
}
}
/**
* Reload interview config from file. Used by manual command and live reload.
*/
private async reloadInterviewConfig(): Promise<void> {
try {
const result = await InterviewConfigLoader.loadConfig(
this.app,
this.settings.interviewConfigPath
);
if (result.errors.length > 0) {
console.warn("Interview config reloaded with errors:", result.errors);
new Notice(`Interview config reloaded with ${result.errors.length} error(s). Check console.`);
} else {
new Notice(`Interview config reloaded: ${result.config.profiles.length} profile(s)`);
}
this.interviewConfig = result.config;
console.log("Interview config reloaded", {
version: result.config.version,
profileCount: result.config.profiles.length,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("not found") || msg.includes("not found in vault")) {
new Notice("interview_config.yaml not found. Configure path in plugin settings.");
} else {
new Notice(`Failed to reload interview config: ${msg}`);
}
console.error(e);
}
}
}

View File

@ -5,6 +5,9 @@ export interface MindnetSettings {
strictMode: boolean;
showCanonicalHints: boolean;
chainDirection: "forward" | "backward" | "both";
interviewConfigPath: string; // vault-relativ
autoStartInterviewOnCreate: boolean;
interceptUnresolvedLinkClicks: boolean;
}
export const DEFAULT_SETTINGS: MindnetSettings = {
@ -14,6 +17,9 @@ export interface MindnetSettings {
strictMode: false,
showCanonicalHints: false,
chainDirection: "forward",
interviewConfigPath: "_system/dictionary/interview_config.yaml",
autoStartInterviewOnCreate: false,
interceptUnresolvedLinkClicks: true,
};
/**

View File

@ -0,0 +1,102 @@
import { describe, it, expect } from "vitest";
import {
extractTargetFromData,
extractBasenameFromTarget,
} from "../../interview/extractTargetFromAnchor";
describe("extractBasenameFromTarget", () => {
it("preserves simple basename", () => {
expect(extractBasenameFromTarget("test-note")).toBe("test-note");
});
it("preserves spaces and case", () => {
expect(extractBasenameFromTarget("My Note")).toBe("My Note");
expect(extractBasenameFromTarget("My Test Note")).toBe("My Test Note");
});
it("removes alias separator", () => {
expect(extractBasenameFromTarget("test-note|Alias Text")).toBe("test-note");
expect(extractBasenameFromTarget("My Note|Display Name")).toBe("My Note");
});
it("removes heading separator", () => {
expect(extractBasenameFromTarget("test-note#Section")).toBe("test-note");
expect(extractBasenameFromTarget("My Note#Section")).toBe("My Note");
});
it("removes alias and heading", () => {
expect(extractBasenameFromTarget("test-note#Section|Alias")).toBe("test-note");
expect(extractBasenameFromTarget("My Note#Section|Alias")).toBe("My Note");
});
it("handles alias before heading", () => {
expect(extractBasenameFromTarget("test-note|Alias#Section")).toBe("test-note");
});
it("trims whitespace", () => {
expect(extractBasenameFromTarget(" test-note ")).toBe("test-note");
expect(extractBasenameFromTarget(" My Note ")).toBe("My Note");
});
});
describe("extractTargetFromData", () => {
it("extracts from data-href attribute", () => {
const result = extractTargetFromData("test-note", null);
expect(result).toBe("test-note");
});
it("extracts from data-href with alias", () => {
const result = extractTargetFromData("test-note|Alias Text", null);
expect(result).toBe("test-note");
});
it("extracts from data-href with heading", () => {
const result = extractTargetFromData("test-note#Section", null);
expect(result).toBe("test-note");
});
it("extracts from data-href with alias and heading", () => {
const result = extractTargetFromData("test-note#Section|Alias", null);
expect(result).toBe("test-note");
});
it("preserves spaces from data-href", () => {
const result = extractTargetFromData("My Note", null);
expect(result).toBe("My Note");
});
it("preserves spaces from data-href with alias", () => {
const result = extractTargetFromData("My Note|Display", null);
expect(result).toBe("My Note");
});
it("falls back to text content if data-href missing", () => {
const result = extractTargetFromData(null, "test-note");
expect(result).toBe("test-note");
});
it("falls back to text content with alias", () => {
const result = extractTargetFromData(null, "test-note|Alias Text");
expect(result).toBe("test-note");
});
it("preserves spaces from text content", () => {
const result = extractTargetFromData(null, "My Note");
expect(result).toBe("My Note");
});
it("returns null if neither data-href nor text content", () => {
const result = extractTargetFromData(null, null);
expect(result).toBe(null);
});
it("prefers data-href over text content", () => {
const result = extractTargetFromData("data-href-note", "text-note");
expect(result).toBe("data-href-note");
});
it("trims whitespace from text content", () => {
const result = extractTargetFromData(null, " test-note ");
expect(result).toBe("test-note");
});
});

View File

@ -0,0 +1,348 @@
import { describe, it, expect } from "vitest";
import { parseInterviewConfig, validateConfig } from "../../interview/parseInterviewConfig";
import type { InterviewConfig } from "../../interview/types";
describe("parseInterviewConfig", () => {
const fixtureYaml = `
version: "2.0"
frontmatterWhitelist:
- title
- id
- date
- tags
profiles:
- key: profile1
label: "Test Profile 1"
note_type: "note"
group: "test"
defaults:
template: "default"
steps:
- type: loop
key: items_loop
label: "Items Loop"
items:
- type: capture_frontmatter
key: title_field
label: "Title"
field: title
required: true
- type: capture_text
key: content_field
label: "Content"
required: false
- type: llm_dialog
key: llm_step
label: "LLM Dialog"
prompt: "What is the main topic?"
system_prompt: "You are a helpful assistant."
model: "gpt-4"
temperature: 0.7
max_tokens: 500
- key: profile2
label: "Test Profile 2"
note_type: "interview"
steps:
- type: capture_frontmatter
key: date_field
field: date
required: false
- type: llm_dialog
key: analysis
prompt: "Analyze the content"
`;
it("parses YAML correctly", () => {
const result = parseInterviewConfig(fixtureYaml);
expect(result.errors.length).toBe(0);
expect(result.config.version).toBe("2.0");
expect(result.config.frontmatterWhitelist).toEqual(["title", "id", "date", "tags"]);
expect(result.config.profiles.length).toBe(2);
});
it("parses profile structure correctly", () => {
const result = parseInterviewConfig(fixtureYaml);
const profile1 = result.config.profiles[0];
if (!profile1) throw new Error("Expected profile1");
expect(profile1.key).toBe("profile1");
expect(profile1.label).toBe("Test Profile 1");
expect(profile1.note_type).toBe("note");
expect(profile1.group).toBe("test");
expect(profile1.defaults).toEqual({ template: "default" });
expect(profile1.steps.length).toBe(2);
});
it("parses loop step correctly", () => {
const result = parseInterviewConfig(fixtureYaml);
const profile1 = result.config.profiles[0];
if (!profile1) throw new Error("Expected profile1");
const loopStep = profile1.steps[0];
if (!loopStep || loopStep.type !== "loop") {
throw new Error("Expected loop step");
}
expect(loopStep.key).toBe("items_loop");
expect(loopStep.label).toBe("Items Loop");
expect(loopStep.items.length).toBe(2);
const firstItem = loopStep.items[0];
if (!firstItem || firstItem.type !== "capture_frontmatter") {
throw new Error("Expected capture_frontmatter step");
}
expect(firstItem.key).toBe("title_field");
expect(firstItem.field).toBe("title");
expect(firstItem.required).toBe(true);
});
it("parses llm_dialog step correctly", () => {
const result = parseInterviewConfig(fixtureYaml);
const profile1 = result.config.profiles[0];
if (!profile1) throw new Error("Expected profile1");
const llmStep = profile1.steps[1];
if (!llmStep || llmStep.type !== "llm_dialog") {
throw new Error("Expected llm_dialog step");
}
expect(llmStep.key).toBe("llm_step");
expect(llmStep.prompt).toBe("What is the main topic?");
expect(llmStep.system_prompt).toBe("You are a helpful assistant.");
expect(llmStep.model).toBe("gpt-4");
expect(llmStep.temperature).toBe(0.7);
expect(llmStep.max_tokens).toBe(500);
});
it("parses capture_frontmatter step correctly", () => {
const result = parseInterviewConfig(fixtureYaml);
const profile2 = result.config.profiles[1];
if (!profile2) throw new Error("Expected profile2");
const captureStep = profile2.steps[0];
if (!captureStep || captureStep.type !== "capture_frontmatter") {
throw new Error("Expected capture_frontmatter step");
}
expect(captureStep.key).toBe("date_field");
expect(captureStep.field).toBe("date");
expect(captureStep.required).toBe(false);
});
it("parses capture_text step correctly", () => {
const result = parseInterviewConfig(fixtureYaml);
const profile1 = result.config.profiles[0];
if (!profile1) throw new Error("Expected profile1");
const loopStep = profile1.steps[0];
if (!loopStep || loopStep.type !== "loop") {
throw new Error("Expected loop step");
}
const captureTextStep = loopStep.items[1];
if (!captureTextStep || captureTextStep.type !== "capture_text") {
throw new Error("Expected capture_text step");
}
expect(captureTextStep.key).toBe("content_field");
expect(captureTextStep.required).toBe(false);
});
it("validates unique profile keys", () => {
const yamlWithDuplicate = `
version: "2.0"
frontmatterWhitelist: []
profiles:
- key: profile1
label: "Profile 1"
note_type: "note"
steps: []
- key: profile1
label: "Profile 1 Duplicate"
note_type: "note"
steps: []
`;
const result = parseInterviewConfig(yamlWithDuplicate);
const validationErrors = validateConfig(result.config);
expect(validationErrors.some(e => e.includes("Duplicate profile key"))).toBe(true);
});
it("validates capture_frontmatter.field in whitelist", () => {
const yamlWithInvalidField = `
version: "2.0"
frontmatterWhitelist:
- title
- id
profiles:
- key: profile1
label: "Profile 1"
note_type: "note"
steps:
- type: capture_frontmatter
key: invalid_field
field: invalid_field_name
`;
const result = parseInterviewConfig(yamlWithInvalidField);
const validationErrors = validateConfig(result.config);
expect(
validationErrors.some(e =>
e.includes("capture_frontmatter field 'invalid_field_name' not in frontmatterWhitelist")
)
).toBe(true);
});
it("validates capture_frontmatter.field in nested loop", () => {
const yamlWithNestedInvalidField = `
version: "2.0"
frontmatterWhitelist:
- title
profiles:
- key: profile1
label: "Profile 1"
note_type: "note"
steps:
- type: loop
key: items
items:
- type: capture_frontmatter
key: invalid
field: invalid_field
`;
const result = parseInterviewConfig(yamlWithNestedInvalidField);
const validationErrors = validateConfig(result.config);
expect(
validationErrors.some(e =>
e.includes("capture_frontmatter field 'invalid_field' not in frontmatterWhitelist")
)
).toBe(true);
});
it("handles invalid YAML gracefully", () => {
const invalidYaml = "invalid: yaml: content: [";
const result = parseInterviewConfig(invalidYaml);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.config.profiles.length).toBe(0);
});
it("handles missing required fields", () => {
const incompleteYaml = `
version: "2.0"
frontmatterWhitelist: []
profiles:
- key: profile1
label: "Profile 1"
# missing note_type
steps: []
`;
const result = parseInterviewConfig(incompleteYaml);
// Profile should be skipped due to missing note_type
expect(result.config.profiles.length).toBe(0);
expect(result.errors.length).toBeGreaterThan(0);
});
it("parses YAML with 'kind' and 'id' fields (real-world format)", () => {
const yamlWithKindAndId = `
version: 2
frontmatter_whitelist:
- id
- title
- type
profiles:
- key: experience_hub
group: experience
label: "Experience Hub"
note_type: experience
defaults:
status: active
steps:
- id: title
kind: capture_frontmatter
field: title
label: "Hub Titel"
required: true
- id: items
kind: loop
label: "Erlebnisse"
steps:
- id: item_text
kind: capture_text
label: "Erlebnis"
required: true
- id: review
kind: review
label: "Review & Apply"
`;
const result = parseInterviewConfig(yamlWithKindAndId);
// Log errors for debugging
if (result.errors.length > 0) {
console.log("Parse errors:", result.errors);
}
expect(result.errors.length).toBe(0);
expect(result.config.profiles.length).toBe(1);
const profile = result.config.profiles[0];
if (!profile) throw new Error("Expected profile");
expect(profile.key).toBe("experience_hub");
expect(profile.label).toBe("Experience Hub");
expect(profile.note_type).toBe("experience");
expect(profile.group).toBe("experience");
expect(profile.steps.length).toBe(3);
// First step: capture_frontmatter
const titleStep = profile.steps[0];
if (!titleStep || titleStep.type !== "capture_frontmatter") {
throw new Error("Expected capture_frontmatter step");
}
expect(titleStep.key).toBe("title");
expect(titleStep.field).toBe("title");
expect(titleStep.label).toBe("Hub Titel");
expect(titleStep.required).toBe(true);
// Second step: loop
const loopStep = profile.steps[1];
if (!loopStep || loopStep.type !== "loop") {
throw new Error("Expected loop step");
}
expect(loopStep.key).toBe("items");
expect(loopStep.label).toBe("Erlebnisse");
expect(loopStep.items.length).toBe(1);
const nestedStep = loopStep.items[0];
if (!nestedStep || nestedStep.type !== "capture_text") {
throw new Error("Expected capture_text step in loop");
}
expect(nestedStep.key).toBe("item_text");
expect(nestedStep.label).toBe("Erlebnis");
expect(nestedStep.required).toBe(true);
// Third step: review
const reviewStep = profile.steps[2];
if (!reviewStep || reviewStep.type !== "review") {
throw new Error("Expected review step");
}
expect(reviewStep.key).toBe("review");
expect(reviewStep.label).toBe("Review & Apply");
});
});

View File

@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { slugify } from "../../interview/slugify";
describe("slugify", () => {
it("converts simple text to slug", () => {
expect(slugify("Hello World")).toBe("hello_world");
});
it("handles special characters", () => {
expect(slugify("Hello-World!")).toBe("hello_world");
});
it("handles accented characters", () => {
expect(slugify("Café")).toBe("cafe");
expect(slugify("Müller")).toBe("muller");
});
it("handles numbers", () => {
expect(slugify("Test123")).toBe("test123");
});
it("collapses multiple separators", () => {
expect(slugify("Hello---World")).toBe("hello_world");
expect(slugify("Hello World")).toBe("hello_world");
});
it("removes leading/trailing separators", () => {
expect(slugify("---Hello---")).toBe("hello");
expect(slugify("___Test___")).toBe("test");
});
it("handles empty string", () => {
expect(slugify("")).toBe("");
});
it("handles only special characters", () => {
expect(slugify("!!!")).toBe("");
expect(slugify("---")).toBe("");
});
it("preserves underscores", () => {
expect(slugify("test_case")).toBe("test_case");
});
it("handles mixed case", () => {
expect(slugify("HelloWorld")).toBe("helloworld");
});
it("handles unicode characters", () => {
expect(slugify("Hello 世界")).toBe("hello");
});
});

View File

@ -0,0 +1,162 @@
import { describe, it, expect } from "vitest";
import { writeFrontmatter } from "../../interview/writeFrontmatter";
describe("writeFrontmatter", () => {
it("writes basic frontmatter", () => {
const result = writeFrontmatter({
id: "test123",
title: "Test Title",
noteType: "note",
interviewProfile: "profile1",
frontmatterWhitelist: [],
});
expect(result).toContain("id: test123");
expect(result).toContain("title: Test Title");
expect(result).toContain("type: note");
expect(result).toContain("interview_profile: profile1");
expect(result).toContain("---");
});
it("includes whitelisted defaults", () => {
const result = writeFrontmatter({
id: "test123",
title: "Test Title",
noteType: "note",
interviewProfile: "profile1",
defaults: {
template: "default",
author: "John",
invalid: "should not appear",
},
frontmatterWhitelist: ["template", "author"],
});
expect(result).toContain("template: default");
expect(result).toContain("author: John");
expect(result).not.toContain("invalid:");
});
it("quotes strings with special characters", () => {
const result = writeFrontmatter({
id: "test123",
title: "Test: Title",
noteType: "note",
interviewProfile: "profile1",
frontmatterWhitelist: [],
});
expect(result).toContain('title: "Test: Title"');
});
it("handles numbers", () => {
const result = writeFrontmatter({
id: "test123",
title: "Test",
noteType: "note",
interviewProfile: "profile1",
defaults: {
count: 42,
rating: 4.5,
},
frontmatterWhitelist: ["count", "rating"],
});
expect(result).toContain("count: 42");
expect(result).toContain("rating: 4.5");
});
it("handles booleans", () => {
const result = writeFrontmatter({
id: "test123",
title: "Test",
noteType: "note",
interviewProfile: "profile1",
defaults: {
published: true,
draft: false,
},
frontmatterWhitelist: ["published", "draft"],
});
expect(result).toContain("published: true");
expect(result).toContain("draft: false");
});
it("handles null values", () => {
const result = writeFrontmatter({
id: "test123",
title: "Test",
noteType: "note",
interviewProfile: "profile1",
defaults: {
author: null,
},
frontmatterWhitelist: ["author"],
});
expect(result).toContain("author: null");
});
it("handles strings with quotes", () => {
const result = writeFrontmatter({
id: "test123",
title: 'Test "Quote" Title',
noteType: "note",
interviewProfile: "profile1",
frontmatterWhitelist: [],
});
expect(result).toContain('title: "Test \\"Quote\\" Title"');
});
it("handles strings with newlines", () => {
const result = writeFrontmatter({
id: "test123",
title: "Test\nMulti\nLine",
noteType: "note",
interviewProfile: "profile1",
frontmatterWhitelist: [],
});
// YAML will format multiline strings differently, so we just check it's quoted
expect(result).toContain('title: "');
expect(result).toContain("Test");
expect(result).toContain("Multi");
expect(result).toContain("Line");
});
it("handles arrays and objects as JSON strings", () => {
const result = writeFrontmatter({
id: "test123",
title: "Test",
noteType: "note",
interviewProfile: "profile1",
defaults: {
tags: ["tag1", "tag2"],
meta: { key: "value" },
},
frontmatterWhitelist: ["tags", "meta"],
});
expect(result).toContain('tags: "');
expect(result).toContain('meta: "');
});
it("skips non-whitelisted defaults", () => {
const result = writeFrontmatter({
id: "test123",
title: "Test",
noteType: "note",
interviewProfile: "profile1",
defaults: {
allowed: "yes",
forbidden: "no",
},
frontmatterWhitelist: ["allowed"],
});
expect(result).toContain("allowed: yes");
expect(result).not.toContain("forbidden:");
});
});

View File

@ -0,0 +1,857 @@
import {
App,
Modal,
Notice,
Setting,
TFile,
TextAreaComponent,
TextComponent,
} from "obsidian";
import type { InterviewProfile, InterviewStep } from "../interview/types";
import {
type WizardState,
createWizardState,
getCurrentStep,
getNextStepIndex,
getPreviousStepIndex,
canGoNext,
canGoBack,
type Patch,
flattenSteps,
} from "../interview/wizardState";
import { extractFrontmatterId } from "../parser/parseFrontmatter";
export interface WizardResult {
applied: boolean;
patches: Patch[];
}
export class InterviewWizardModal extends Modal {
state: WizardState;
file: TFile;
fileContent: string;
onSubmit: (result: WizardResult) => void;
onSaveAndExit: (result: WizardResult) => void;
profileKey: string;
// Store current input values to save on navigation
private currentInputValues: Map<string, string> = new Map();
constructor(
app: App,
profile: InterviewProfile,
file: TFile,
fileContent: string,
onSubmit: (result: WizardResult) => void,
onSaveAndExit: (result: WizardResult) => void
) {
super(app);
// Validate profile
if (!profile) {
new Notice(`Interview profile not found`);
throw new Error("Profile is required");
}
this.profileKey = profile.key;
// Log profile info
const stepKinds = profile.steps?.map(s => s.type) || [];
console.log("Wizard profile", {
key: profile.key,
stepCount: profile.steps?.length,
kinds: stepKinds,
});
// Validate steps - only throw if profile.steps is actually empty
if (!profile.steps || profile.steps.length === 0) {
new Notice(`Interview has no steps for profile: ${profile.key}`);
throw new Error("Profile has no steps");
}
// Check flattened steps after creation (will be logged in createWizardState)
// If flattened is empty but profile.steps is not, that's a flattenSteps bug
this.state = createWizardState(profile);
// Validate flattened steps after creation
const flat = flattenSteps(profile.steps);
if (flat.length === 0 && profile.steps.length > 0) {
console.error("Flatten produced 0 steps but profile has steps", {
profileKey: profile.key,
originalStepCount: profile.steps.length,
originalKinds: stepKinds,
});
new Notice(`Flatten produced 0 steps (check flattenSteps) for profile: ${profile.key}`);
throw new Error("FlattenSteps produced empty result");
}
this.file = file;
this.fileContent = fileContent;
this.onSubmit = onSubmit;
this.onSaveAndExit = onSaveAndExit;
}
onOpen(): void {
const fileName = this.file.basename || this.file.name.replace(/\.md$/, "");
console.log("=== WIZARD START ===", {
profileKey: this.profileKey,
file: this.file.path,
fileName: fileName,
stepCount: this.state.profile.steps?.length || 0,
});
this.renderStep();
}
renderStep(): void {
const { contentEl } = this;
contentEl.empty();
const step = getCurrentStep(this.state);
console.log("Render step", {
stepIndex: this.state.currentStepIndex,
stepType: step?.type || "null",
stepKey: step?.key || "null",
stepLabel: step?.label || "null",
totalSteps: flattenSteps(this.state.profile.steps).length,
});
if (!step) {
// Check if we're at the end legitimately or if there's an error
const steps = flattenSteps(this.state.profile.steps);
if (steps.length === 0) {
new Notice(`Interview has no steps for profile: ${this.profileKey}`);
this.close();
return;
}
if (this.state.currentStepIndex >= steps.length) {
contentEl.createEl("p", { text: "Interview completed" });
return;
}
// Unexpected: step is null but we should have one
console.error("Unexpected: step is null", {
currentStepIndex: this.state.currentStepIndex,
stepCount: steps.length,
profileKey: this.profileKey,
});
new Notice(`Error: Could not load step ${this.state.currentStepIndex + 1}`);
this.close();
return;
}
// Check if ID exists
const hasId = this.checkIdExists();
if (!hasId) {
const warningEl = contentEl.createEl("div", {
cls: "interview-warning",
});
warningEl.createEl("p", {
text: "⚠️ Note missing frontmatter ID",
});
new Setting(warningEl).addButton((button) => {
button.setButtonText("Generate ID").onClick(() => {
this.generateId();
});
});
}
// Render step based on type
switch (step.type) {
case "instruction":
this.renderInstructionStep(step, contentEl);
break;
case "capture_text":
this.renderCaptureTextStep(step, contentEl);
break;
case "capture_frontmatter":
this.renderCaptureFrontmatterStep(step, contentEl);
break;
case "loop":
this.renderLoopStep(step, contentEl);
break;
case "llm_dialog":
this.renderLLMDialogStep(step, contentEl);
break;
case "review":
this.renderReviewStep(step, contentEl);
break;
}
// Navigation buttons
this.renderNavigation(contentEl);
}
checkIdExists(): boolean {
const id = extractFrontmatterId(this.fileContent);
return id !== null && id.trim() !== "";
}
generateId(): void {
const id = `note_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
const frontmatterMatch = this.fileContent.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch && frontmatterMatch[1]) {
// Add id to existing frontmatter
const frontmatter = frontmatterMatch[1];
if (!frontmatter.includes("id:")) {
const newFrontmatter = `${frontmatter}\nid: ${id}`;
this.fileContent = this.fileContent.replace(
/^---\n([\s\S]*?)\n---/,
`---\n${newFrontmatter}\n---`
);
this.state.patches.push({
type: "frontmatter",
field: "id",
value: id,
});
new Notice("ID generated");
this.renderStep();
}
}
}
renderInstructionStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "instruction") return;
containerEl.createEl("h2", {
text: step.label || "Instruction",
});
containerEl.createEl("div", {
text: step.content,
cls: "interview-instruction-content",
});
}
renderCaptureTextStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "capture_text") return;
const existingValue =
(this.state.collectedData.get(step.key) as string) || "";
console.log("Render capture_text step", {
stepKey: step.key,
stepLabel: step.label,
existingValue: existingValue,
valueLength: existingValue.length,
});
containerEl.createEl("h2", {
text: step.label || "Enter Text",
});
new Setting(containerEl).addTextArea((text) => {
text.setValue(existingValue);
// Store initial value
this.currentInputValues.set(step.key, existingValue);
text.onChange((value) => {
console.log("Text field changed", {
stepKey: step.key,
valueLength: value.length,
valuePreview: value.substring(0, 50) + (value.length > 50 ? "..." : ""),
});
// Update stored value
this.currentInputValues.set(step.key, value);
this.state.collectedData.set(step.key, value);
});
text.inputEl.rows = 10;
text.inputEl.focus();
});
}
renderCaptureFrontmatterStep(
step: InterviewStep,
containerEl: HTMLElement
): void {
if (step.type !== "capture_frontmatter") return;
if (!step.field) return;
// Prefill with filename if field is "title" and no existing value
const fileName = this.file.basename || this.file.name.replace(/\.md$/, "");
const existingValue =
(this.state.collectedData.get(step.key) as string) || "";
// Use filename as default for title field if no existing value
const defaultValue = step.field === "title" && !existingValue
? fileName
: existingValue;
console.log("Render capture_frontmatter step", {
stepKey: step.key,
field: step.field,
existingValue: existingValue,
fileName: fileName,
defaultValue: defaultValue,
});
containerEl.createEl("h2", {
text: step.label || `Enter ${step.field}`,
});
new Setting(containerEl)
.setName(step.field)
.addText((text) => {
text.setValue(defaultValue);
// Store initial value
this.currentInputValues.set(step.key, defaultValue);
text.onChange((value) => {
console.log("Frontmatter field changed", {
stepKey: step.key,
field: step.field,
value: value,
});
// Update stored value
this.currentInputValues.set(step.key, value);
this.state.collectedData.set(step.key, value);
// Update or add patch
const existingPatchIndex = this.state.patches.findIndex(
p => p.type === "frontmatter" && p.field === step.field
);
const patch = {
type: "frontmatter" as const,
field: step.field!,
value: value,
};
if (existingPatchIndex >= 0) {
this.state.patches[existingPatchIndex] = patch;
} else {
this.state.patches.push(patch);
}
});
text.inputEl.focus();
});
}
renderLoopStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "loop") return;
const items = this.state.loopContexts.get(step.key) || [];
const currentItemIndex = items.length; // Next item index
console.log("Render loop step", {
stepKey: step.key,
stepLabel: step.label,
itemsCount: items.length,
nestedStepsCount: step.items.length,
nestedStepTypes: step.items.map(s => s.type),
currentItemIndex: currentItemIndex,
});
containerEl.createEl("h2", {
text: step.label || "Loop",
});
// Show existing items
if (items.length > 0) {
const itemsList = containerEl.createEl("div", { cls: "loop-items-list" });
itemsList.createEl("h3", { text: `Gesammelte Items (${items.length}):` });
const ul = itemsList.createEl("ul");
for (let i = 0; i < items.length; i++) {
const item = items[i];
const li = ul.createEl("li");
li.createSpan({ text: `Item ${i + 1}: ` });
if (typeof item === "object" && item !== null) {
// Show readable format instead of JSON
const itemEntries = Object.entries(item);
if (itemEntries.length > 0) {
const itemText = itemEntries
.map(([key, value]) => {
const nestedStep = step.items.find(s => s.key === key);
const label = nestedStep?.label || key;
return `${label}: ${String(value).substring(0, 50)}${String(value).length > 50 ? "..." : ""}`;
})
.join(", ");
li.createSpan({ text: itemText });
} else {
li.createSpan({ text: "Empty" });
}
} else {
li.createSpan({ text: String(item) });
}
}
itemsList.createEl("p", {
text: "Sie können weitere Items hinzufügen oder mit 'Next' fortfahren.",
cls: "interview-note",
});
}
// Render nested steps for current item
if (step.items.length > 0) {
containerEl.createEl("h3", {
text: items.length === 0 ? "First Item" : `Item ${items.length + 1}`,
});
// Create a temporary state for the current loop item
const itemDataKey = `${step.key}_item_${currentItemIndex}`;
const itemData = new Map<string, unknown>();
// Render each nested step
for (const nestedStep of step.items) {
if (nestedStep.type === "capture_text") {
const existingValue = (itemData.get(nestedStep.key) as string) || "";
const inputKey = `${itemDataKey}_${nestedStep.key}`;
containerEl.createEl("h4", {
text: nestedStep.label || nestedStep.key,
});
// Show prompt if available
if (nestedStep.prompt) {
containerEl.createEl("p", {
text: nestedStep.prompt,
cls: "interview-prompt",
});
}
new Setting(containerEl).addTextArea((text) => {
text.setValue(existingValue);
this.currentInputValues.set(inputKey, existingValue);
text.onChange((value) => {
this.currentInputValues.set(inputKey, value);
itemData.set(nestedStep.key, value);
});
text.inputEl.rows = 10;
text.inputEl.style.width = "100%";
text.inputEl.style.minHeight = "150px";
});
} else if (nestedStep.type === "capture_frontmatter") {
const existingValue = (itemData.get(nestedStep.key) as string) || "";
const inputKey = `${itemDataKey}_${nestedStep.key}`;
containerEl.createEl("h4", {
text: nestedStep.label || nestedStep.field || nestedStep.key,
});
new Setting(containerEl)
.setName(nestedStep.field || nestedStep.key)
.addText((text) => {
text.setValue(existingValue);
this.currentInputValues.set(inputKey, existingValue);
text.onChange((value) => {
this.currentInputValues.set(inputKey, value);
itemData.set(nestedStep.key, value);
});
});
}
}
// Add item button
new Setting(containerEl).addButton((button) => {
button
.setButtonText("Add Item")
.setCta()
.onClick(() => {
// Collect all item data
const itemDataObj: Record<string, unknown> = {};
let hasData = false;
for (const nestedStep of step.items) {
const inputKey = `${itemDataKey}_${nestedStep.key}`;
const value = this.currentInputValues.get(inputKey);
if (value !== undefined && String(value).trim()) {
itemDataObj[nestedStep.key] = value;
hasData = true;
}
}
if (!hasData) {
new Notice("Please enter at least one field before adding an item");
return;
}
// Add to loop context
items.push(itemDataObj);
this.state.loopContexts.set(step.key, items);
// Clear input values for this item
for (const nestedStep of step.items) {
const inputKey = `${itemDataKey}_${nestedStep.key}`;
this.currentInputValues.delete(inputKey);
}
console.log("Loop item added", {
stepKey: step.key,
itemIndex: items.length - 1,
itemData: itemDataObj,
totalItems: items.length,
});
new Notice(`Item ${items.length} added. You can add more or click Next to continue.`);
// Re-render to show new item and reset form
this.renderStep();
});
});
// Show hint if no items yet
if (items.length === 0) {
containerEl.createEl("p", {
text: "⚠️ Please add at least one item before continuing",
cls: "interview-warning",
});
}
}
}
renderLLMDialogStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "llm_dialog") return;
containerEl.createEl("h2", {
text: step.label || "LLM Dialog",
});
containerEl.createEl("p", {
text: `Prompt: ${step.prompt}`,
});
const existingValue =
(this.state.collectedData.get(step.key) as string) || "";
new Setting(containerEl)
.setName("Response")
.addTextArea((text) => {
text.setValue(existingValue).onChange((value) => {
this.state.collectedData.set(step.key, value);
});
text.inputEl.rows = 10;
});
containerEl.createEl("p", {
text: "Note: LLM dialog requires manual input in this version",
cls: "interview-note",
});
}
renderReviewStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "review") return;
containerEl.createEl("h2", {
text: step.label || "Review",
});
containerEl.createEl("p", {
text: "Review collected data and patches:",
});
// Show collected data
const dataList = containerEl.createEl("ul");
for (const [key, value] of this.state.collectedData.entries()) {
const li = dataList.createEl("li");
li.createEl("strong", { text: `${key}: ` });
li.createSpan({ text: String(value) });
}
// Show patches
const patchesList = containerEl.createEl("ul");
for (const patch of this.state.patches) {
const li = patchesList.createEl("li");
if (patch.type === "frontmatter") {
li.createSpan({
text: `Frontmatter ${patch.field}: ${String(patch.value)}`,
});
} else {
li.createSpan({ text: `Content patch` });
}
}
}
renderNavigation(containerEl: HTMLElement): void {
const navContainer = containerEl.createEl("div", {
cls: "interview-navigation",
});
// Back button
new Setting(navContainer)
.addButton((button) => {
button.setButtonText("Back").setDisabled(!canGoBack(this.state));
button.onClick(() => {
this.goBack();
});
})
.addButton((button) => {
const step = getCurrentStep(this.state);
const isReview = step?.type === "review";
const isLoop = step?.type === "loop";
// For loop steps, disable Next until at least one item is added
const loopItems = isLoop ? (this.state.loopContexts.get(step.key) || []) : [];
const canProceedLoop = !isLoop || loopItems.length > 0;
button
.setButtonText(isReview ? "Apply & Finish" : "Next")
.setCta()
.setDisabled((!canGoNext(this.state) && !isReview) || !canProceedLoop);
button.onClick(() => {
if (isReview) {
console.log("=== FINISH WIZARD (Apply & Finish) ===");
// Save current step data before finishing
const currentStep = getCurrentStep(this.state);
if (currentStep) {
this.saveCurrentStepData(currentStep);
}
this.applyPatches();
this.onSubmit({ applied: true, patches: this.state.patches });
this.close();
} else {
this.goNext();
}
});
})
.addButton((button) => {
button.setButtonText("Skip").onClick(() => {
this.goNext();
});
})
.addButton((button) => {
button.setButtonText("Save & Exit").onClick(() => {
console.log("=== SAVE & EXIT ===");
this.applyPatches();
this.onSaveAndExit({
applied: true,
patches: this.state.patches,
});
this.close();
});
});
}
goNext(): void {
const currentStep = getCurrentStep(this.state);
// Save current step data before navigating
if (currentStep) {
this.saveCurrentStepData(currentStep);
}
const nextIndex = getNextStepIndex(this.state);
console.log("Navigate: Next", {
fromIndex: this.state.currentStepIndex,
toIndex: nextIndex,
currentStepKey: currentStep?.key,
currentStepType: currentStep?.type,
});
if (nextIndex !== null) {
this.state.stepHistory.push(this.state.currentStepIndex);
this.state.currentStepIndex = nextIndex;
this.renderStep();
} else {
console.log("Cannot go next: already at last step");
}
}
/**
* Save data from current step before navigating away.
*/
private saveCurrentStepData(step: InterviewStep): void {
const currentValue = this.currentInputValues.get(step.key);
if (currentValue !== undefined) {
console.log("Save current step data before navigation", {
stepKey: step.key,
stepType: step.type,
value: typeof currentValue === "string"
? (currentValue.length > 50 ? currentValue.substring(0, 50) + "..." : currentValue)
: currentValue,
});
this.state.collectedData.set(step.key, currentValue);
// For frontmatter steps, also update patch
if (step.type === "capture_frontmatter" && step.field) {
const existingPatchIndex = this.state.patches.findIndex(
p => p.type === "frontmatter" && p.field === step.field
);
const patch = {
type: "frontmatter" as const,
field: step.field,
value: currentValue,
};
if (existingPatchIndex >= 0) {
this.state.patches[existingPatchIndex] = patch;
} else {
this.state.patches.push(patch);
}
}
}
}
goBack(): void {
const prevIndex = getPreviousStepIndex(this.state);
if (prevIndex !== null) {
this.state.currentStepIndex = prevIndex;
this.state.stepHistory.pop();
this.renderStep();
}
}
async applyPatches(): Promise<void> {
console.log("=== APPLY PATCHES ===", {
patchCount: this.state.patches.length,
patches: this.state.patches.map(p => ({
type: p.type,
field: p.field,
value: typeof p.value === "string"
? (p.value.length > 50 ? p.value.substring(0, 50) + "..." : p.value)
: p.value,
})),
collectedDataKeys: Array.from(this.state.collectedData.keys()),
loopContexts: Array.from(this.state.loopContexts.entries()).map(([key, items]) => ({
loopKey: key,
itemsCount: items.length,
})),
});
let updatedContent = this.fileContent;
// Apply frontmatter patches
for (const patch of this.state.patches) {
if (patch.type === "frontmatter" && patch.field) {
console.log("Apply frontmatter patch", {
field: patch.field,
value: patch.value,
});
updatedContent = this.applyFrontmatterPatch(
updatedContent,
patch.field,
patch.value
);
}
}
// Apply loop data to content
for (const [loopKey, items] of this.state.loopContexts.entries()) {
if (items.length > 0) {
// Find the loop step to get section info
const loopStep = this.state.profile.steps.find(s => s.type === "loop" && s.key === loopKey);
if (loopStep && loopStep.type === "loop") {
// Get section from first nested step if available (check for section property in YAML)
const firstNestedStep = loopStep.items[0];
let sectionHeader = "## Items";
// Check if nested step has section property
if (firstNestedStep && firstNestedStep.type === "capture_text" && firstNestedStep.section) {
sectionHeader = firstNestedStep.section;
}
// Build content for loop items
const loopContent: string[] = [];
loopContent.push(sectionHeader);
loopContent.push("");
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && typeof item === "object") {
// Render each field from the item
for (const [fieldKey, fieldValue] of Object.entries(item)) {
if (fieldValue && String(fieldValue).trim()) {
// Find nested step to get label
const nestedStep = loopStep.items.find(s => s.key === fieldKey);
const label = nestedStep?.label || fieldKey;
// For multiple items, add item number
if (items.length > 1) {
loopContent.push(`#### ${label} ${i + 1}`);
}
loopContent.push(String(fieldValue));
loopContent.push("");
}
}
}
}
// Append loop content to file
const loopContentStr = loopContent.join("\n");
updatedContent = updatedContent.trimEnd() + "\n\n" + loopContentStr;
console.log("Apply loop content", {
loopKey: loopKey,
itemsCount: items.length,
contentLength: loopContentStr.length,
sectionHeader: sectionHeader,
});
}
}
}
// Write updated content
console.log("Write file", {
file: this.file.path,
contentLength: updatedContent.length,
contentPreview: updatedContent.substring(0, 200) + "...",
});
await this.app.vault.modify(this.file, updatedContent);
this.fileContent = updatedContent;
console.log("=== PATCHES APPLIED ===");
new Notice("Changes applied");
}
applyFrontmatterPatch(
content: string,
field: string,
value: unknown
): string {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch || !frontmatterMatch[1]) {
return content;
}
const frontmatter = frontmatterMatch[1];
const fieldRegex = new RegExp(`^${field}\\s*:.*$`, "m");
let updatedFrontmatter: string;
if (fieldRegex.test(frontmatter)) {
// Update existing field
updatedFrontmatter = frontmatter.replace(
fieldRegex,
`${field}: ${this.formatYamlValue(value)}`
);
} else {
// Add new field
updatedFrontmatter = `${frontmatter}\n${field}: ${this.formatYamlValue(value)}`;
}
return content.replace(
/^---\n([\s\S]*?)\n---/,
`---\n${updatedFrontmatter}\n---`
);
}
formatYamlValue(value: unknown): string {
if (typeof value === "string") {
if (
value.includes(":") ||
value.includes('"') ||
value.includes("\n") ||
value.trim() !== value
) {
return `"${value.replace(/"/g, '\\"')}"`;
}
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (value === null || value === undefined) {
return "null";
}
return JSON.stringify(value);
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
}
}

View File

@ -126,5 +126,72 @@ export class MindnetSettingTab extends PluginSettingTab {
}
})
);
// Interview config path
new Setting(containerEl)
.setName("Interview config path")
.setDesc("Vault-relative path to the interview config YAML file")
.addText((text) =>
text
.setPlaceholder("_system/dictionary/interview_config.yaml")
.setValue(this.plugin.settings.interviewConfigPath)
.onChange(async (value) => {
this.plugin.settings.interviewConfigPath = value;
await this.plugin.saveSettings();
})
)
.addButton((button) =>
button
.setButtonText("Validate")
.setCta()
.onClick(async () => {
try {
const { InterviewConfigLoader } = await import("../interview/InterviewConfigLoader");
const result = await InterviewConfigLoader.loadConfig(
this.app,
this.plugin.settings.interviewConfigPath
);
if (result.errors.length > 0) {
new Notice(
`Interview config loaded with ${result.errors.length} error(s). Check console.`
);
console.warn("Interview config errors:", result.errors);
} else {
new Notice(
`Interview config file found (${result.config.profiles.length} profile(s))`
);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to load interview config: ${msg}`);
}
})
);
// Auto-start interview toggle
new Setting(containerEl)
.setName("Auto-start interview on create")
.setDesc("Automatically start interview wizard when creating a note from profile")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoStartInterviewOnCreate)
.onChange(async (value) => {
this.plugin.settings.autoStartInterviewOnCreate = value;
await this.plugin.saveSettings();
})
);
// Intercept unresolved links toggle
new Setting(containerEl)
.setName("Intercept unresolved link clicks")
.setDesc("Open profile selection when clicking unresolved internal links")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.interceptUnresolvedLinkClicks)
.onChange(async (value) => {
this.plugin.settings.interceptUnresolvedLinkClicks = value;
await this.plugin.saveSettings();
})
);
}
}

View File

@ -0,0 +1,161 @@
import { App, Modal, Notice, Setting, TFile } from "obsidian";
import type { InterviewConfig, InterviewProfile } from "../interview/types";
export interface ProfileSelectionResult {
profile: InterviewProfile;
title: string;
}
export class ProfileSelectionModal extends Modal {
result: ProfileSelectionResult | null = null;
onSubmit: (result: ProfileSelectionResult) => void;
config: InterviewConfig;
initialTitle: string;
constructor(
app: App,
config: InterviewConfig,
onSubmit: (result: ProfileSelectionResult) => void,
initialTitle: string = ""
) {
super(app);
this.config = config;
this.onSubmit = onSubmit;
this.initialTitle = initialTitle;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Create Note from Profile" });
// Group profiles by group
const grouped = new Map<string, InterviewProfile[]>();
const ungrouped: InterviewProfile[] = [];
for (const profile of this.config.profiles) {
if (profile.group) {
if (!grouped.has(profile.group)) {
grouped.set(profile.group, []);
}
const groupProfiles = grouped.get(profile.group);
if (groupProfiles) {
groupProfiles.push(profile);
}
} else {
ungrouped.push(profile);
}
}
let selectedProfile: InterviewProfile | null = null;
let titleInput: string = this.initialTitle;
// Title input
const titleSetting = new Setting(contentEl)
.setName("Title")
.setDesc("Note title")
.addText((text) => {
text.setValue(this.initialTitle).onChange((value) => {
titleInput = value;
});
text.inputEl.focus();
text.inputEl.select();
});
// Profile selection (grouped)
for (const [groupName, profiles] of grouped.entries()) {
contentEl.createEl("h3", { text: groupName });
for (const profile of profiles) {
const setting = new Setting(contentEl)
.setName(profile.label)
.setDesc(`Type: ${profile.note_type}`)
.addButton((button) => {
button.setButtonText("Select").onClick(() => {
// Clear previous selection
contentEl.querySelectorAll(".profile-selected").forEach((el) => {
if (el instanceof HTMLElement) {
el.removeClass("profile-selected");
}
});
// Mark as selected
setting.settingEl.addClass("profile-selected");
selectedProfile = profile;
// Log selection
console.log("Profile selected", {
key: profile.key,
label: profile.label,
noteType: profile.note_type,
stepCount: profile.steps?.length || 0,
});
});
});
}
}
// Ungrouped profiles
if (ungrouped.length > 0) {
if (grouped.size > 0) {
contentEl.createEl("h3", { text: "Other" });
}
for (const profile of ungrouped) {
const setting = new Setting(contentEl)
.setName(profile.label)
.setDesc(`Type: ${profile.note_type}`)
.addButton((button) => {
button.setButtonText("Select").onClick(() => {
// Clear previous selection
contentEl.querySelectorAll(".profile-selected").forEach((el) => {
if (el instanceof HTMLElement) {
el.removeClass("profile-selected");
}
});
// Mark as selected
setting.settingEl.addClass("profile-selected");
selectedProfile = profile;
// Log selection
console.log("Profile selected", {
key: profile.key,
label: profile.label,
noteType: profile.note_type,
stepCount: profile.steps?.length || 0,
});
});
});
}
}
// Submit button
new Setting(contentEl).addButton((button) => {
button
.setButtonText("Create Note")
.setCta()
.onClick(() => {
if (!selectedProfile) {
new Notice("Please select a profile");
return;
}
if (!titleInput.trim()) {
new Notice("Please enter a title");
return;
}
this.result = {
profile: selectedProfile,
title: titleInput.trim(),
};
this.onSubmit(this.result);
this.close();
});
});
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
}
}

View File

@ -6,3 +6,58 @@ available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/
/* Profile selection visual feedback */
.profile-selected {
background-color: var(--interactive-hover);
border-left: 3px solid var(--interactive-accent);
padding-left: calc(var(--size-4-2) - 3px);
}
.profile-selected .setting-item-name {
font-weight: 600;
}
/* Interview wizard styles */
.interview-prompt {
color: var(--text-muted);
font-style: italic;
margin-bottom: 1em;
}
.interview-warning {
background-color: var(--background-modifier-error);
color: var(--text-error);
padding: 0.5em;
border-radius: 4px;
margin: 1em 0;
}
.interview-note {
color: var(--text-muted);
font-size: 0.9em;
margin-top: 0.5em;
}
.loop-items-list {
margin: 1em 0;
padding: 1em;
background-color: var(--background-secondary);
border-radius: 4px;
}
.loop-items-list ul {
margin: 0.5em 0;
padding-left: 1.5em;
}
.loop-items-list li {
margin: 0.25em 0;
}
/* TextArea improvements */
.setting-item textarea {
width: 100% !important;
min-height: 150px;
resize: vertical;
}