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