Enhance interview functionality and settings; add YAML dependency
- 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:
parent
d577283af6
commit
bab84549e2
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -14,7 +14,8 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "0-BSD",
|
"license": "0-BSD",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"obsidian": "latest"
|
"obsidian": "latest",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.30.1",
|
"@eslint/js": "9.30.1",
|
||||||
|
|
@ -6669,7 +6670,6 @@
|
||||||
"version": "2.8.2",
|
"version": "2.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,19 @@
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"license": "0-BSD",
|
"license": "0-BSD",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "9.30.1",
|
||||||
"@types/node": "^16.11.6",
|
"@types/node": "^16.11.6",
|
||||||
"esbuild": "0.25.5",
|
"esbuild": "0.25.5",
|
||||||
"eslint-plugin-obsidianmd": "0.1.9",
|
"eslint-plugin-obsidianmd": "0.1.9",
|
||||||
"globals": "14.0.0",
|
"globals": "14.0.0",
|
||||||
|
"jiti": "2.6.1",
|
||||||
"tslib": "2.4.0",
|
"tslib": "2.4.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "8.35.1",
|
"typescript-eslint": "8.35.1",
|
||||||
"@eslint/js": "9.30.1",
|
|
||||||
"jiti": "2.6.1",
|
|
||||||
"vitest": "^1.6.0"
|
"vitest": "^1.6.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"obsidian": "latest"
|
"obsidian": "latest",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
src/interview/InterviewConfigLoader.ts
Normal file
20
src/interview/InterviewConfigLoader.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/interview/extractTargetFromAnchor.ts
Normal file
52
src/interview/extractTargetFromAnchor.ts
Normal 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);
|
||||||
|
}
|
||||||
363
src/interview/parseInterviewConfig.ts
Normal file
363
src/interview/parseInterviewConfig.ts
Normal 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
15
src/interview/slugify.ts
Normal 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
74
src/interview/types.ts
Normal 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;
|
||||||
|
}
|
||||||
106
src/interview/wizardState.ts
Normal file
106
src/interview/wizardState.ts
Normal 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;
|
||||||
|
}
|
||||||
93
src/interview/writeFrontmatter.ts
Normal file
93
src/interview/writeFrontmatter.ts
Normal 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 '""';
|
||||||
|
}
|
||||||
|
}
|
||||||
301
src/main.ts
301
src/main.ts
|
|
@ -11,11 +11,20 @@ import { buildIndex } from "./graph/GraphIndex";
|
||||||
import { traverseForward, traverseBackward, type Path } from "./graph/traverse";
|
import { traverseForward, traverseBackward, type Path } from "./graph/traverse";
|
||||||
import { renderChainReport } from "./graph/renderChainReport";
|
import { renderChainReport } from "./graph/renderChainReport";
|
||||||
import { extractFrontmatterId } from "./parser/parseFrontmatter";
|
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 {
|
export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||||
settings: MindnetSettings;
|
settings: MindnetSettings;
|
||||||
private vocabulary: Vocabulary | null = null;
|
private vocabulary: Vocabulary | null = null;
|
||||||
private reloadDebounceTimer: number | null = null;
|
private reloadDebounceTimer: number | null = null;
|
||||||
|
private interviewConfig: InterviewConfig | null = null;
|
||||||
|
private interviewConfigReloadDebounceTimer: number | null = null;
|
||||||
|
|
||||||
async onload(): Promise<void> {
|
async onload(): Promise<void> {
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
|
|
@ -23,6 +32,63 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||||
// Add settings tab
|
// Add settings tab
|
||||||
this.addSettingTab(new MindnetSettingTab(this.app, this));
|
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
|
// Register live reload for edge vocabulary file
|
||||||
this.registerEvent(
|
this.registerEvent(
|
||||||
this.app.vault.on("modify", async (file: TFile) => {
|
this.app.vault.on("modify", async (file: TFile) => {
|
||||||
|
|
@ -43,6 +109,22 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||||
this.reloadDebounceTimer = null;
|
this.reloadDebounceTimer = null;
|
||||||
}, 200);
|
}, 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 {
|
onunload(): void {
|
||||||
|
|
@ -313,4 +543,75 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||||
console.error(e);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ export interface MindnetSettings {
|
||||||
strictMode: boolean;
|
strictMode: boolean;
|
||||||
showCanonicalHints: boolean;
|
showCanonicalHints: boolean;
|
||||||
chainDirection: "forward" | "backward" | "both";
|
chainDirection: "forward" | "backward" | "both";
|
||||||
|
interviewConfigPath: string; // vault-relativ
|
||||||
|
autoStartInterviewOnCreate: boolean;
|
||||||
|
interceptUnresolvedLinkClicks: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: MindnetSettings = {
|
export const DEFAULT_SETTINGS: MindnetSettings = {
|
||||||
|
|
@ -14,6 +17,9 @@ export interface MindnetSettings {
|
||||||
strictMode: false,
|
strictMode: false,
|
||||||
showCanonicalHints: false,
|
showCanonicalHints: false,
|
||||||
chainDirection: "forward",
|
chainDirection: "forward",
|
||||||
|
interviewConfigPath: "_system/dictionary/interview_config.yaml",
|
||||||
|
autoStartInterviewOnCreate: false,
|
||||||
|
interceptUnresolvedLinkClicks: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
102
src/tests/interview/extractTargetFromAnchor.test.ts
Normal file
102
src/tests/interview/extractTargetFromAnchor.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
348
src/tests/interview/parseInterviewConfig.test.ts
Normal file
348
src/tests/interview/parseInterviewConfig.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/tests/interview/slugify.test.ts
Normal file
52
src/tests/interview/slugify.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
162
src/tests/interview/writeFrontmatter.test.ts
Normal file
162
src/tests/interview/writeFrontmatter.test.ts
Normal 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:");
|
||||||
|
});
|
||||||
|
});
|
||||||
857
src/ui/InterviewWizardModal.ts
Normal file
857
src/ui/InterviewWizardModal.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
161
src/ui/ProfileSelectionModal.ts
Normal file
161
src/ui/ProfileSelectionModal.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
55
styles.css
55
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.
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user