sbuffkin_hexmaker/src/WorkflowEditorModal.ts
isaprettycoolguy@protonmail.com 583c3f656e create workflow tool
2026-03-17 16:18:42 -04:00

433 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, Modal, TFile, Notice } from "obsidian";
import type DuckmagePlugin from "./DuckmagePlugin";
import { normalizeFolder } from "./utils";
import {
parseWorkflow,
buildWorkflowContent,
generateDefaultTemplate,
requiredPlaceholders,
stepPlaceholder,
stepVarName,
type WorkflowStep,
} from "./workflow";
/** Convert a table basename to a default label: spaces → underscores. */
function labelFromBasename(basename: string): string {
return basename.replace(/ /g, "_");
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export class WorkflowEditorModal extends Modal {
private flushAndSave: (() => Promise<void>) | null = null;
private dragInitialized = false;
constructor(
app: App,
private plugin: DuckmagePlugin,
private file: TFile,
private onSaved?: () => void,
) {
super(app);
}
async onOpen(): Promise<void> {
this.titleEl.setText(`Edit workflow: ${this.file.basename}`);
const { contentEl } = this;
contentEl.addClass("duckmage-wf-editor");
const rawContent = await this.app.vault.read(this.file);
const workflow = parseWorkflow(rawContent, this.file.basename);
// Working copies
const steps: WorkflowStep[] = workflow.steps.map(s => ({ ...s }));
let resultsFolder = workflow.resultsFolder ?? "";
let templateContent = "";
// Load template file if it exists
if (workflow.templateFile) {
const tmplFile = this.app.vault.getAbstractFileByPath(workflow.templateFile);
if (tmplFile instanceof TFile) {
templateContent = await this.app.vault.read(tmplFile);
}
}
if (!templateContent) {
templateContent = generateDefaultTemplate(steps);
}
// All table files in the tables folder for the picker
const tablesFolder = normalizeFolder(this.plugin.settings.tablesFolder);
const tableFiles = this.app.vault.getMarkdownFiles()
.filter(f => !tablesFolder || f.path.startsWith(tablesFolder + "/"))
.filter(f => !f.basename.startsWith("_"))
.sort((a, b) => a.path.localeCompare(b.path));
// ── Name row ─────────────────────────────────────────────────────
const nameRow = contentEl.createDiv({ cls: "duckmage-table-editor-name-row" });
nameRow.createEl("label", { text: "Name", cls: "duckmage-table-editor-name-label" });
const nameInput = nameRow.createEl("input", { type: "text", cls: "duckmage-table-editor-name-input" });
nameInput.value = this.file.basename;
const doRename = async () => {
const newName = nameInput.value.trim();
if (!newName || newName === this.file.basename) return;
const dir = this.file.path.slice(0, this.file.path.length - this.file.name.length);
const newPath = dir + newName + ".md";
try {
await this.app.fileManager.renameFile(this.file, newPath);
this.titleEl.setText(`Edit workflow: ${this.file.basename}`);
this.onSaved?.();
} catch {
nameInput.value = this.file.basename;
}
};
nameInput.addEventListener("blur", doRename);
nameInput.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter") { e.preventDefault(); nameInput.blur(); }
if (e.key === "Escape") { nameInput.value = this.file.basename; nameInput.blur(); }
});
// ── Steps section ────────────────────────────────────────────────
contentEl.createEl("p", { text: "Steps", cls: "duckmage-table-editor-heading" });
const stepsEl = contentEl.createDiv({ cls: "duckmage-wf-editor-steps" });
// ── Add step area (inputs row + button row) ───────────────────────
contentEl.createEl("p", { text: "Add step", cls: "duckmage-table-editor-heading" });
const addArea = contentEl.createDiv({ cls: "duckmage-wf-editor-add-area" });
const addRow = addArea.createDiv({ cls: "duckmage-wf-editor-step-row" });
// ── Results folder row ────────────────────────────────────────────
const rfRow = contentEl.createDiv({ cls: "duckmage-table-editor-name-row" });
rfRow.style.marginTop = "8px";
rfRow.createEl("label", { text: "Results folder", cls: "duckmage-table-editor-name-label" });
const rfInput = rfRow.createEl("input", { type: "text", cls: "duckmage-table-editor-name-input" });
rfInput.placeholder = "world/results";
rfInput.value = resultsFolder;
rfInput.addEventListener("input", () => { resultsFolder = normalizeFolder(rfInput.value.trim()); });
// ── Template section ──────────────────────────────────────────────
contentEl.createEl("p", { text: "Template", cls: "duckmage-table-editor-heading" });
const templateArea = contentEl.createEl("textarea", { cls: "duckmage-wf-template-area" });
templateArea.value = templateContent;
templateArea.addEventListener("input", () => { templateContent = templateArea.value; updateValidation(); });
const validationEl = contentEl.createDiv();
validationEl.style.marginTop = "4px";
validationEl.style.fontSize = "0.85em";
// ── Validation + helpers (declared here so renderSteps can call them) ──
const updateValidation = () => {
const required = requiredPlaceholders(steps);
const missing = required.filter(p => !templateContent.includes(p));
if (missing.length === 0) {
validationEl.className = "duckmage-wf-validation-ok";
validationEl.setText(required.length > 0 ? "✓ All placeholders present" : "");
} else {
validationEl.className = "duckmage-wf-validation-err";
validationEl.setText(`Missing: ${missing.join(", ")}`);
}
};
const getTemplateLabel = (step: WorkflowStep): string =>
step.label || labelFromBasename(step.tablePath.split("/").pop() ?? step.tablePath);
const syncLabelInTemplate = (oldLabel: string, newLabel: string): void => {
if (!oldLabel || oldLabel === newLabel) return;
templateContent = templateContent.replace(
new RegExp(`^## ${escapeRegex(oldLabel)}$`, "m"),
`## ${newLabel}`,
);
templateArea.value = templateContent;
updateValidation();
};
const appendStepToTemplate = (step: WorkflowStep): void => {
const label = getTemplateLabel(step);
const lines: string[] = [`## ${label}`, ""];
for (let r = 0; r < step.rolls; r++) {
lines.push(stepPlaceholder(step, r));
}
lines.push("");
templateContent = templateContent.trimEnd() + "\n\n" + lines.join("\n");
templateArea.value = templateContent;
updateValidation();
};
const syncRollsInTemplate = (step: WorkflowStep, oldRolls: number, newRolls: number): void => {
if (newRolls <= oldRolls) return;
const varName = stepVarName(step);
if (oldRolls === 1) {
// Replace single $varname with $varname_1 … $varname_newRolls
const singleEscaped = escapeRegex(`$${varName}`);
const newText = Array.from({ length: newRolls }, (_, i) => `$${varName}_${i + 1}`).join("\n");
templateContent = templateContent.replace(
new RegExp(singleEscaped + "(?!_\\d)", "g"),
newText,
);
} else {
// Append new placeholder(s) after the last existing one
const lastPlaceholder = `$${varName}_${oldRolls}`;
const addedText = Array.from({ length: newRolls - oldRolls }, (_, i) => `$${varName}_${oldRolls + i + 1}`).join("\n");
templateContent = templateContent.replace(
new RegExp(escapeRegex(lastPlaceholder), "g"),
`${lastPlaceholder}\n${addedText}`,
);
}
templateArea.value = templateContent;
updateValidation();
};
// ── Step rendering ────────────────────────────────────────────────
let dragSrcIndex = -1;
const renderSteps = () => {
stepsEl.empty();
if (steps.length === 0) {
stepsEl.createSpan({ text: "No steps yet.", cls: "duckmage-rt-empty" });
}
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const row = stepsEl.createDiv({ cls: "duckmage-wf-editor-step-row" });
row.draggable = true;
const handle = row.createSpan({ cls: "duckmage-table-editor-drag-handle", text: "⠿" });
handle.title = "Drag to reorder";
// Table picker
const tableSelect = row.createEl("select");
tableSelect.createEl("option", { value: "", text: "— select table —" });
for (const tf of tableFiles) {
const val = tf.path.slice(0, -3);
const optLabel = tablesFolder ? tf.path.slice(tablesFolder.length + 1, -3) : val;
const opt = tableSelect.createEl("option", { value: val, text: optLabel });
if (val === step.tablePath) opt.selected = true;
}
if (step.tablePath && !tableFiles.some(tf => tf.path.slice(0, -3) === step.tablePath)) {
const opt = tableSelect.createEl("option", { value: step.tablePath, text: step.tablePath });
opt.selected = true;
}
tableSelect.addEventListener("change", () => {
steps[i].tablePath = tableSelect.value;
const newAutoLabel = labelFromBasename(tableSelect.value.split("/").pop() ?? tableSelect.value);
const oldLabel = prevLabel;
labelInput.value = newAutoLabel;
steps[i].label = newAutoLabel || undefined;
syncLabelInTemplate(oldLabel, newAutoLabel);
prevLabel = newAutoLabel;
updateValidation();
});
// Rolls input
const rollsInput = row.createEl("input", { type: "number" });
rollsInput.min = "1";
rollsInput.value = String(step.rolls);
rollsInput.style.width = "52px";
rollsInput.title = "Number of rolls";
rollsInput.addEventListener("input", () => {
const oldRolls = steps[i].rolls;
const newRolls = Math.max(1, parseInt(rollsInput.value, 10) || 1);
steps[i].rolls = newRolls;
if (newRolls > oldRolls) syncRollsInTemplate(steps[i], oldRolls, newRolls);
updateValidation();
});
// Label input — syncs with template heading on change
const labelInput = row.createEl("input", { type: "text" });
labelInput.placeholder = "Label…";
labelInput.value = step.label ?? "";
labelInput.style.flex = "1";
let prevLabel = getTemplateLabel(step);
labelInput.addEventListener("input", () => {
const newLabel = labelInput.value.trim() || labelFromBasename(step.tablePath.split("/").pop() ?? step.tablePath);
syncLabelInTemplate(prevLabel, newLabel);
prevLabel = newLabel;
steps[i].label = labelInput.value.trim() || undefined;
});
// Delete button
const delBtn = row.createEl("button", { text: "×", cls: "duckmage-table-editor-del" });
delBtn.title = "Remove step";
delBtn.addEventListener("click", () => {
steps.splice(i, 1);
renderSteps();
updateValidation();
});
// Drag-and-drop
row.addEventListener("dragstart", (e: DragEvent) => {
dragSrcIndex = i;
row.addClass("duckmage-table-editor-dragging");
e.dataTransfer?.setDragImage(row, 0, 0);
});
row.addEventListener("dragend", () => {
row.removeClass("duckmage-table-editor-dragging");
stepsEl.querySelectorAll(".duckmage-table-editor-drop-target").forEach(el =>
el.classList.remove("duckmage-table-editor-drop-target"));
});
row.addEventListener("dragover", (e: DragEvent) => {
e.preventDefault();
stepsEl.querySelectorAll(".duckmage-table-editor-drop-target").forEach(el =>
el.classList.remove("duckmage-table-editor-drop-target"));
row.addClass("duckmage-table-editor-drop-target");
});
row.addEventListener("dragleave", () => {
row.removeClass("duckmage-table-editor-drop-target");
});
row.addEventListener("drop", (e: DragEvent) => {
e.preventDefault();
if (dragSrcIndex === -1 || dragSrcIndex === i) return;
const [moved] = steps.splice(dragSrcIndex, 1);
steps.splice(i, 0, moved);
dragSrcIndex = -1;
renderSteps();
updateValidation();
});
}
};
// ── Populate add-step row controls ────────────────────────────────
const addTableSelect = addRow.createEl("select");
addTableSelect.createEl("option", { value: "", text: "— select table —" });
for (const tf of tableFiles) {
const val = tf.path.slice(0, -3);
const optLabel = tablesFolder ? tf.path.slice(tablesFolder.length + 1, -3) : val;
addTableSelect.createEl("option", { value: val, text: optLabel });
}
const addRollsInput = addRow.createEl("input", { type: "number" });
addRollsInput.min = "1";
addRollsInput.value = "1";
addRollsInput.style.width = "52px";
addRollsInput.title = "Number of rolls";
const addLabelInput = addRow.createEl("input", { type: "text" });
addLabelInput.placeholder = "Label (auto)…";
addLabelInput.style.flex = "1";
// Auto-fill label from table name when picker changes
addTableSelect.addEventListener("change", () => {
if (!addLabelInput.value.trim() && addTableSelect.value) {
const basename = addTableSelect.value.split("/").pop() ?? addTableSelect.value;
addLabelInput.value = labelFromBasename(basename);
}
});
const addBtnRow = addArea.createDiv({ cls: "duckmage-wf-editor-add-btn-row" });
const addBtn = addBtnRow.createEl("button", { text: "Add step", cls: "mod-cta" });
addBtn.addEventListener("click", () => {
const tablePath = addTableSelect.value;
if (!tablePath) return;
const rolls = Math.max(1, parseInt(addRollsInput.value, 10) || 1);
const rawLabel = addLabelInput.value.trim();
const autoLabel = labelFromBasename(tablePath.split("/").pop() ?? tablePath);
const label = rawLabel || autoLabel;
steps.push({ tablePath, rolls, label });
addTableSelect.value = "";
addRollsInput.value = "1";
addLabelInput.value = "";
renderSteps();
appendStepToTemplate(steps[steps.length - 1]);
});
// Reset template
const resetTemplateBtn = contentEl.createEl("button", { text: "Reset template" });
resetTemplateBtn.style.marginTop = "4px";
resetTemplateBtn.addEventListener("click", () => {
const defaultTmpl = generateDefaultTemplate(steps);
const current = templateArea.value.trim();
if (current && current !== defaultTmpl.trim()) {
if (!confirm("Reset template? Your custom content will be overwritten.")) return;
}
templateArea.value = defaultTmpl;
templateContent = defaultTmpl;
updateValidation();
});
renderSteps();
updateValidation();
// ── Footer ────────────────────────────────────────────────────────
const footer = contentEl.createDiv({ cls: "duckmage-table-editor-footer" });
this.flushAndSave = async () => {
const wfFolder = normalizeFolder(this.plugin.settings.workflowsFolder);
const templatePath = `${wfFolder}/templates/${this.file.basename}.md`;
const updatedWorkflow = {
name: this.file.basename,
resultsFolder: resultsFolder || undefined,
templateFile: templatePath,
steps,
};
try {
await this.app.vault.modify(this.file, buildWorkflowContent(updatedWorkflow));
const templateDir = `${wfFolder}/templates`;
if (!this.app.vault.getAbstractFileByPath(templateDir)) {
try { await this.app.vault.createFolder(templateDir); } catch { /* may exist */ }
}
const tmplFile = this.app.vault.getAbstractFileByPath(templatePath);
if (tmplFile instanceof TFile) {
await this.app.vault.modify(tmplFile, templateContent);
} else {
await this.app.vault.create(templatePath, templateContent);
}
this.onSaved?.();
} catch (err) {
new Notice(`Could not save workflow: ${err}`);
}
};
footer.createEl("button", { text: "Close", cls: "mod-cta" }).addEventListener("click", () => this.close());
this.makeDraggable();
}
onClose(): void {
void this.flushAndSave?.();
this.flushAndSave = null;
this.contentEl.empty();
}
private makeDraggable(): void {
if (this.dragInitialized) return;
this.dragInitialized = true;
const modal = this.modalEl;
modal.addClass("duckmage-editor-modal-drag");
modal.style.position = "absolute";
modal.style.left = "50%";
modal.style.top = "50%";
modal.style.transform = "translate(-50%, -50%)";
modal.style.margin = "0";
modal.addEventListener("mousedown", (e: MouseEvent) => {
const modalContent = modal.querySelector<HTMLElement>(".modal-content");
if (modalContent && e.clientY >= modalContent.getBoundingClientRect().top) return;
if ((e.target as HTMLElement).closest("button, a, input, select, textarea")) return;
e.preventDefault();
const r = modal.getBoundingClientRect();
modal.style.transform = "none";
modal.style.left = `${r.left}px`;
modal.style.top = `${r.top}px`;
const sx = e.clientX, sy = e.clientY;
const ox = r.left, oy = r.top;
const onMove = (ev: MouseEvent) => {
modal.style.left = `${ox + ev.clientX - sx}px`;
modal.style.top = `${oy + ev.clientY - sy}px`;
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
}
}