diff --git a/package-lock.json b/package-lock.json index 5ac9ac7..036681c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index c6e943c..4c7580f 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/interview/InterviewConfigLoader.ts b/src/interview/InterviewConfigLoader.ts new file mode 100644 index 0000000..f65824a --- /dev/null +++ b/src/interview/InterviewConfigLoader.ts @@ -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); + } +} diff --git a/src/interview/extractTargetFromAnchor.ts b/src/interview/extractTargetFromAnchor.ts new file mode 100644 index 0000000..8960320 --- /dev/null +++ b/src/interview/extractTargetFromAnchor.ts @@ -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); +} diff --git a/src/interview/parseInterviewConfig.ts b/src/interview/parseInterviewConfig.ts new file mode 100644 index 0000000..90d3f6d --- /dev/null +++ b/src/interview/parseInterviewConfig.ts @@ -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; + + // 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, 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, + 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; + } + + // 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); + if (step) { + profile.steps.push(step); + } + } + } + + return profile; +} + +function parseStep(raw: Record): 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); + 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(); + 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); + } + } +} diff --git a/src/interview/slugify.ts b/src/interview/slugify.ts new file mode 100644 index 0000000..1dd356a --- /dev/null +++ b/src/interview/slugify.ts @@ -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 +} diff --git a/src/interview/types.ts b/src/interview/types.ts new file mode 100644 index 0000000..127c99c --- /dev/null +++ b/src/interview/types.ts @@ -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; + 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; +} diff --git a/src/interview/wizardState.ts b/src/interview/wizardState.ts new file mode 100644 index 0000000..1144036 --- /dev/null +++ b/src/interview/wizardState.ts @@ -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; // key -> value + loopContexts: Map; // 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; +} diff --git a/src/interview/writeFrontmatter.ts b/src/interview/writeFrontmatter.ts new file mode 100644 index 0000000..3a5625e --- /dev/null +++ b/src/interview/writeFrontmatter.ts @@ -0,0 +1,93 @@ +import type { InterviewProfile } from "./types"; + +export interface FrontmatterOptions { + id: string; + title: string; + noteType: string; + interviewProfile: string; + defaults?: Record; + 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 '""'; + } +} diff --git a/src/main.ts b/src/main.ts index 186e70c..6b30d4c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { 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 { + 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 { + // 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 { + 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 { + 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); + } + } } diff --git a/src/settings.ts b/src/settings.ts index 02be79c..0449ec1 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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, }; /** diff --git a/src/tests/interview/extractTargetFromAnchor.test.ts b/src/tests/interview/extractTargetFromAnchor.test.ts new file mode 100644 index 0000000..1543132 --- /dev/null +++ b/src/tests/interview/extractTargetFromAnchor.test.ts @@ -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"); + }); +}); diff --git a/src/tests/interview/parseInterviewConfig.test.ts b/src/tests/interview/parseInterviewConfig.test.ts new file mode 100644 index 0000000..e4c4d66 --- /dev/null +++ b/src/tests/interview/parseInterviewConfig.test.ts @@ -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"); + }); +}); diff --git a/src/tests/interview/slugify.test.ts b/src/tests/interview/slugify.test.ts new file mode 100644 index 0000000..da91b92 --- /dev/null +++ b/src/tests/interview/slugify.test.ts @@ -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"); + }); +}); diff --git a/src/tests/interview/writeFrontmatter.test.ts b/src/tests/interview/writeFrontmatter.test.ts new file mode 100644 index 0000000..37bcdb8 --- /dev/null +++ b/src/tests/interview/writeFrontmatter.test.ts @@ -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:"); + }); +}); diff --git a/src/ui/InterviewWizardModal.ts b/src/ui/InterviewWizardModal.ts new file mode 100644 index 0000000..e10c45a --- /dev/null +++ b/src/ui/InterviewWizardModal.ts @@ -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 = 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(); + + // 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 = {}; + 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 { + 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(); + } +} diff --git a/src/ui/MindnetSettingTab.ts b/src/ui/MindnetSettingTab.ts index 6535b6b..3edccd9 100644 --- a/src/ui/MindnetSettingTab.ts +++ b/src/ui/MindnetSettingTab.ts @@ -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(); + }) + ); } } diff --git a/src/ui/ProfileSelectionModal.ts b/src/ui/ProfileSelectionModal.ts new file mode 100644 index 0000000..7bb0a1f --- /dev/null +++ b/src/ui/ProfileSelectionModal.ts @@ -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(); + 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(); + } +} diff --git a/styles.css b/styles.css index 71cc60f..369d727 100644 --- a/styles.css +++ b/styles.css @@ -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; +}