mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 14:30:24 +00:00
433 lines
17 KiB
TypeScript
433 lines
17 KiB
TypeScript
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);
|
||
});
|
||
}
|
||
}
|