create workflow tool

This commit is contained in:
isaprettycoolguy@protonmail.com 2026-03-17 16:18:42 -04:00
parent 2ebee21308
commit 583c3f656e
12 changed files with 1426 additions and 19 deletions

View file

@ -40,7 +40,8 @@ export class DuckmageSettingTab extends PluginSettingTab {
["questsFolder", `${world}/quests`],
["featuresFolder", `${world}/features`],
["factionsFolder", `${world}/factions`],
["tablesFolder", `${world}/tables`],
["tablesFolder", `${world}/tables`],
["workflowsFolder", `${world}/workflows`],
];
for (const [key, path] of defaults) {
if (!this.plugin.settings[key]) {
@ -166,6 +167,19 @@ export class DuckmageSettingTab extends PluginSettingTab {
}),
);
new Setting(containerEl)
.setName("Workflows folder")
.setDesc("Vault-relative folder for workflow notes. Browsable from the Random Tables view via the Workflows tab.")
.addText(text =>
text
.setPlaceholder("world/workflows")
.setValue(this.plugin.settings.workflowsFolder)
.onChange(async value => {
this.plugin.settings.workflowsFolder = normalizeFolder(value ?? "");
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Default die for new tables")
.setDesc("Die size used when creating new random table notes (d6, d20, d100, etc.).")

View file

@ -1,5 +1,5 @@
import { App, Modal, TFile } from "obsidian";
import { parseRandomTable } from "./randomTable";
import { parseRandomTable, extractPostTableContent } from "./randomTable";
import type { RandomTableEntry } from "./randomTable";
import type DuckmagePlugin from "./DuckmagePlugin";
import { normalizeFolder } from "./utils";
@ -254,6 +254,7 @@ export class RandomTableEditorModal extends Modal {
// Expose so onClose always saves all changes (flushes pending "add row" text first)
this.flushAndSave = async () => {
doAdd(); // flush pending "add row" text if any (no-op if empty)
const suffix = extractPostTableContent(rawContent) || undefined;
let updatedFm = this.setFrontmatterBool(frontmatter, "roll-filter",
rollFilterCb.checked ? false : undefined);
updatedFm = this.setFrontmatterBool(updatedFm, "encounter-filter",
@ -270,7 +271,7 @@ export class RandomTableEditorModal extends Modal {
const rollerLink = rollerLinkMatch ? rollerLinkMatch[0] : "";
const newDescription = descInput.value.trim();
const newPreamble = [rollerLink, newDescription].filter(Boolean).join("\n\n");
const newContent = this.buildContent(updatedFm, newPreamble, entries, linkedFolder || undefined);
const newContent = this.buildContent(updatedFm, newPreamble, entries, linkedFolder || undefined, suffix);
try {
await this.app.vault.modify(this.file, newContent);
this.onSaved?.();
@ -450,7 +451,7 @@ export class RandomTableEditorModal extends Modal {
return afterFm.slice(0, tableMatch.index).trim();
}
private buildContent(frontmatter: string, preamble: string, entries: RandomTableEntry[], linkedFolder?: string): string {
private buildContent(frontmatter: string, preamble: string, entries: RandomTableEntry[], linkedFolder?: string, suffix?: string): string {
const rows = entries.map(e => {
const cell = (linkedFolder || e.isLink) ? `[[${e.result}]]` : e.result;
return `| ${cell} | ${e.weight} |`;
@ -460,6 +461,8 @@ export class RandomTableEditorModal extends Modal {
if (frontmatter) parts.push(frontmatter);
if (preamble) parts.push(preamble);
parts.push(tableBlock);
return parts.join("\n\n") + "\n";
let result = parts.join("\n\n") + "\n";
if (suffix) result += "\n" + suffix;
return result;
}
}

View file

@ -12,15 +12,19 @@ import type DuckmagePlugin from "./DuckmagePlugin";
import { VIEW_TYPE_RANDOM_TABLES } from "./constants";
import { normalizeFolder, makeTableTemplate } from "./utils";
import { RandomTableEditorModal } from "./RandomTableEditorModal";
import { WorkflowEditorModal } from "./WorkflowEditorModal";
import { WorkflowWizardModal } from "./WorkflowWizardModal";
import {
parseRandomTable,
rollOnTable,
getDieRanges,
getOddsLabel,
setDiceInFrontmatter,
extractPostTableContent,
type RandomTable,
type RandomTableEntry,
} from "./randomTable";
import { parseWorkflow } from "./workflow";
const DIE_OPTIONS = [
{ label: "— no die —", value: 0 },
@ -57,8 +61,14 @@ export class RandomTableView extends ItemView {
private filterQuery = "";
private treeInitialized = false;
private linkedFolderMap: Map<string, string> = new Map(); // folder path → table file path
private workflowMap: Map<string, string[]> = new Map(); // table path (no .md) → workflow file paths
private listRefreshTimer: number | null = null;
private detailRefreshTimer: number | null = null;
private viewMode: "tables" | "workflows" = "tables";
private tablesBtn: HTMLButtonElement | null = null;
private workflowsBtn: HTMLButtonElement | null = null;
private tableFooterEl: HTMLElement | null = null;
private workflowFooterEl: HTMLElement | null = null;
constructor(
leaf: WorkspaceLeaf,
@ -82,8 +92,16 @@ export class RandomTableView extends ItemView {
if (state?.filePath) {
const file = this.app.vault.getAbstractFileByPath(state.filePath);
if (file instanceof TFile) {
await this.loadList();
this.loadTable(file);
if (this.isInWorkflowsFolder(file.path)) {
this.viewMode = "workflows";
this.tablesBtn?.removeClass("is-active");
this.workflowsBtn?.addClass("is-active");
await this.loadList();
await this.loadWorkflow(file);
} else {
await this.loadList();
this.loadTable(file);
}
}
}
}
@ -108,6 +126,19 @@ export class RandomTableView extends ItemView {
refreshBtn.title = "Refresh list";
refreshBtn.addEventListener("click", () => this.loadList());
// ── Mode toggle tabs ─────────────────────────────────────────────────
const modeTabs = leftCol.createDiv({ cls: "duckmage-rt-mode-tabs" });
this.tablesBtn = modeTabs.createEl("button", {
text: "Tables",
cls: "duckmage-rt-mode-tab is-active",
});
this.workflowsBtn = modeTabs.createEl("button", {
text: "Workflows",
cls: "duckmage-rt-mode-tab",
});
this.tablesBtn.addEventListener("click", () => this.setViewMode("tables"));
this.workflowsBtn.addEventListener("click", () => this.setViewMode("workflows"));
const searchInput = leftCol.createEl("input", {
type: "text",
cls: "duckmage-rt-search",
@ -143,7 +174,19 @@ export class RandomTableView extends ItemView {
});
const listFooter = leftCol.createDiv({ cls: "duckmage-rt-list-footer" });
const newRow = listFooter.createDiv({ cls: "duckmage-rt-new-row" });
// ── Workflow footer (shown in workflows mode) ──────────────────────────
this.workflowFooterEl = listFooter.createDiv();
this.workflowFooterEl.style.display = "none";
const newWfFooterBtn = this.workflowFooterEl.createEl("button", {
text: "+ New workflow",
cls: "duckmage-rt-new-btn",
});
newWfFooterBtn.addEventListener("click", () => this.createWorkflow());
// ── Table footer (shown in tables mode) ───────────────────────────────
this.tableFooterEl = listFooter.createDiv();
const newRow = this.tableFooterEl.createDiv({ cls: "duckmage-rt-new-row" });
const newInput = newRow.createEl("input", {
type: "text",
cls: "duckmage-rt-new-input",
@ -167,7 +210,7 @@ export class RandomTableView extends ItemView {
text: "+ New",
cls: "duckmage-rt-new-btn",
});
const fromFolderInput = listFooter.createEl("input", {
const fromFolderInput = this.tableFooterEl.createEl("input", {
type: "text",
cls: "duckmage-rt-from-folder-input",
attr: { placeholder: "Generate from folder link (optional)…" },
@ -194,7 +237,7 @@ export class RandomTableView extends ItemView {
.sort((a, b) => a.basename.localeCompare(b.basename));
const rollerLink = this.plugin.buildRollerLink(newPath);
const entryRows = folderFiles
.map((f) => `| ${f.basename} | 1 |`)
.map((f) => `| [[${f.basename}]] | 1 |`)
.join("\n");
content = `---\ndice: ${this.plugin.settings.defaultTableDice}\nlinked-folder: ${srcFolder}\n---\n\n${rollerLink}\n\n| Result | Weight |\n|--------|--------|\n${entryRows || "| | 1 |"}\n`;
} else {
@ -240,10 +283,19 @@ export class RandomTableView extends ItemView {
this.app.vault.on("create", (file) => {
if (file instanceof TFile && !file.basename.startsWith("_") && this.isInTablesFolder(file.path))
this.scheduleListRefresh();
if (file instanceof TFile && !file.basename.startsWith("_") && this.isInWorkflowsFolder(file.path))
this.scheduleListRefresh();
}),
);
this.registerEvent(
this.app.vault.on("delete", (file) => {
if (file instanceof TFile && this.isInWorkflowsFolder(file.path)) {
if (this.activeFile === file) {
this.activeFile = null;
this.detailEl?.empty();
}
this.scheduleListRefresh();
}
if (!(file instanceof TFile) || !this.isInTablesFolder(file.path)) return;
if (this.activeFile === file) {
this.activeFile = null;
@ -257,7 +309,9 @@ export class RandomTableView extends ItemView {
if (!(file instanceof TFile)) return;
const wasIn = this.isInTablesFolder(oldPath);
const isIn = this.isInTablesFolder(file.path);
if (!wasIn && !isIn) return;
const wasInWf = this.isInWorkflowsFolder(oldPath);
const isInWf = this.isInWorkflowsFolder(file.path);
if (!wasIn && !isIn && !wasInWf && !isInWf) return;
if (this.activeFile?.path === oldPath) this.activeFile = file;
this.scheduleListRefresh();
}),
@ -307,13 +361,19 @@ export class RandomTableView extends ItemView {
const table = parseRandomTable(content);
if (table.entries.some((e) => e.result === createdFile.basename))
return;
// Append new row to the markdown table block in the file
// Append new row to the markdown table block in the file, preserving post-table content
const suffix = extractPostTableContent(content);
const newRow = `| ${createdFile.basename} | 1 |`;
const updated = content.replace(
const replaced = content.replace(
/(\| Result \| Weight \|\n\|[-| ]+\|\n)([\s\S]*)$/,
(_, hdr, body) =>
`${hdr}${body.trimEnd() ? body.trimEnd() + "\n" : ""}${newRow}\n`,
(_, hdr, body) => {
const tableLines = body.split("\n")
.filter((l: string) => l.trimStart().startsWith("|"))
.join("\n");
return `${hdr}${tableLines.trimEnd() ? tableLines.trimEnd() + "\n" : ""}${newRow}\n`;
},
);
const updated = suffix ? replaced.trimEnd() + "\n\n" + suffix : replaced;
await this.app.vault.modify(tableFile, updated);
await this.loadList();
if (this.activeFile?.path === tableFilePath) await this.renderDetail();
@ -341,6 +401,27 @@ export class RandomTableView extends ItemView {
return !folder || filePath.startsWith(folder + "/");
}
private isInWorkflowsFolder(filePath: string): boolean {
const folder = normalizeFolder(this.plugin.settings.workflowsFolder);
return !!folder && filePath.startsWith(folder + "/");
}
private setViewMode(mode: "tables" | "workflows"): void {
this.viewMode = mode;
this.tablesBtn?.toggleClass("is-active", mode === "tables");
this.workflowsBtn?.toggleClass("is-active", mode === "workflows");
if (this.tableFooterEl) this.tableFooterEl.style.display = mode === "tables" ? "" : "none";
if (this.workflowFooterEl) this.workflowFooterEl.style.display = mode === "workflows" ? "" : "none";
this.filterQuery = "";
this.activeFile = null;
this.detailEl?.empty();
this.detailEl?.createDiv({
cls: "duckmage-rt-placeholder",
text: mode === "workflows" ? "Select a workflow to view." : "Select a table to view and roll.",
});
void this.loadList();
}
private scheduleListRefresh(): void {
if (this.listRefreshTimer !== null) clearTimeout(this.listRefreshTimer);
this.listRefreshTimer = window.setTimeout(() => {
@ -353,7 +434,11 @@ export class RandomTableView extends ItemView {
if (this.detailRefreshTimer !== null) clearTimeout(this.detailRefreshTimer);
this.detailRefreshTimer = window.setTimeout(() => {
this.detailRefreshTimer = null;
void this.renderDetail();
if (this.viewMode === "workflows") {
void this.renderWorkflowDetail();
} else {
void this.renderDetail();
}
}, 300);
}
@ -413,6 +498,11 @@ export class RandomTableView extends ItemView {
if (!this.listEl) return;
this.listEl.empty();
if (this.viewMode === "workflows") {
await this.loadWorkflowList();
return;
}
const folder = normalizeFolder(this.plugin.settings.tablesFolder);
const prefix = folder ? folder + "/" : "";
@ -442,6 +532,9 @@ export class RandomTableView extends ItemView {
this.linkedFolderMap.set(normalizeFolder(lf as string), file.path);
}
// Rebuild workflow map (table path → [workflow paths])
this.rebuildWorkflowMap();
if (files.length === 0) {
this.listEl.createSpan({
text: "No tables found.",
@ -469,6 +562,196 @@ export class RandomTableView extends ItemView {
this.renderTreeNodes(this.listEl, tree, this.filterQuery !== "");
}
private rebuildWorkflowMap(): void {
this.workflowMap.clear();
const wfFolder = normalizeFolder(this.plugin.settings.workflowsFolder);
if (!wfFolder) return;
const wfFiles = this.app.vault.getMarkdownFiles()
.filter(f => f.path.startsWith(wfFolder + "/") && !f.basename.startsWith("_"));
for (const wf of wfFiles) {
const links = this.app.metadataCache.getFileCache(wf)?.links ?? [];
for (const link of links) {
const dest = this.app.metadataCache.getFirstLinkpathDest(link.link, wf.path);
if (!dest) continue;
const key = dest.path.slice(0, -3);
const existing = this.workflowMap.get(key) ?? [];
existing.push(wf.path);
this.workflowMap.set(key, existing);
}
}
}
private async loadWorkflowList(): Promise<void> {
if (!this.listEl) return;
const wfFolder = normalizeFolder(this.plugin.settings.workflowsFolder);
if (!wfFolder) {
this.listEl.createSpan({
text: "No workflows folder configured. Set it in settings.",
cls: "duckmage-rt-empty",
});
return;
}
// Exclude templates subfolder
const templatesPath = wfFolder + "/templates/";
let files = this.app.vault.getMarkdownFiles()
.filter(f => f.path.startsWith(wfFolder + "/")
&& !f.basename.startsWith("_")
&& !f.path.startsWith(templatesPath))
.sort((a, b) => a.path.localeCompare(b.path));
if (this.filterQuery) {
files = files.filter(f => {
const rel = f.path.slice(wfFolder.length + 1);
return rel.toLowerCase().includes(this.filterQuery);
});
}
if (files.length === 0) {
this.listEl.createSpan({
text: "No workflows found.",
cls: "duckmage-rt-empty",
});
return;
}
const prefix = wfFolder + "/";
const tree = this.buildTree(files, prefix);
this.renderWorkflowTreeNodes(this.listEl, tree);
}
private renderWorkflowTreeNodes(container: HTMLElement, nodes: TreeNode[]): void {
for (const node of nodes) {
if (node.type === "folder") {
const isCollapsed = this.collapsedFolders.has("wf:" + node.path);
const folderEl = container.createDiv({ cls: "duckmage-rt-folder" });
const folderHeader = folderEl.createDiv({ cls: "duckmage-rt-folder-header" });
const arrow = folderHeader.createSpan({
cls: "duckmage-rt-folder-arrow",
text: isCollapsed ? "▶" : "▼",
});
folderHeader.createSpan({ cls: "duckmage-rt-folder-name", text: node.name });
const childrenEl = folderEl.createDiv({ cls: "duckmage-rt-folder-children" });
if (isCollapsed) childrenEl.style.display = "none";
this.renderWorkflowTreeNodes(childrenEl, node.children);
folderHeader.addEventListener("click", () => {
const key = "wf:" + node.path;
const nowCollapsed = !this.collapsedFolders.has(key);
if (nowCollapsed) {
this.collapsedFolders.add(key);
childrenEl.style.display = "none";
arrow.textContent = "▶";
} else {
this.collapsedFolders.delete(key);
childrenEl.style.display = "";
arrow.textContent = "▼";
}
});
} else {
const row = container.createDiv({ cls: "duckmage-rt-workflow-item" });
if (node.file === this.activeFile) row.addClass("is-active");
row.setText(node.file.basename);
row.title = node.file.path;
row.addEventListener("click", () => this.loadWorkflow(node.file));
}
}
}
private async createWorkflow(initialTablePath?: string): Promise<void> {
const wfFolder = normalizeFolder(this.plugin.settings.workflowsFolder);
if (!wfFolder) {
new Notice("Configure a workflows folder in settings first.");
return;
}
if (!this.app.vault.getAbstractFileByPath(wfFolder)) {
try { await this.app.vault.createFolder(wfFolder); } catch { /* may exist */ }
}
// Find unique name
let baseName = "New Workflow";
let i = 2;
while (this.app.vault.getAbstractFileByPath(`${wfFolder}/${baseName}.md`)) {
baseName = `New Workflow ${i++}`;
}
const steps = initialTablePath
? `| [[${initialTablePath}]] | 1 | |\n`
: "";
const content = `---\n---\n\n| Table | Rolls | Label |\n|-------|-------|-------|\n${steps}`;
const newPath = `${wfFolder}/${baseName}.md`;
try {
const file = await this.app.vault.create(newPath, content);
await this.loadList();
await this.loadWorkflow(file);
new WorkflowEditorModal(this.app, this.plugin, file, () => {
void this.loadList();
if (this.activeFile === file) void this.renderWorkflowDetail();
}).open();
} catch (err) {
new Notice(`Could not create workflow: ${err}`);
}
}
private async loadWorkflow(file: TFile): Promise<void> {
this.activeFile = file;
this.listEl?.querySelectorAll<HTMLElement>(".duckmage-rt-workflow-item")
.forEach(el => el.toggleClass("is-active", el.title === file.path));
await this.renderWorkflowDetail();
}
private async renderWorkflowDetail(): Promise<void> {
if (!this.detailEl || !this.activeFile) return;
const file = this.activeFile;
const content = await this.app.vault.read(file);
const workflow = parseWorkflow(content, file.basename);
this.detailEl.empty();
// ── Header ─────────────────────────────────────────────────────────
const header = this.detailEl.createDiv({ cls: "duckmage-rt-detail-header" });
header.createEl("h3", { text: file.basename, cls: "duckmage-rt-detail-title" });
const editLink = header.createEl("a", { text: "Edit", cls: "duckmage-rt-edit-link" });
editLink.addEventListener("click", () => {
new WorkflowEditorModal(this.app, this.plugin, file, () => {
void this.loadList();
if (this.activeFile === file) void this.renderWorkflowDetail();
}).open();
});
const runBtn = this.detailEl.createEl("button", {
text: "Roll workflow",
cls: "duckmage-rt-roll-btn mod-cta",
});
runBtn.style.marginBottom = "12px";
runBtn.addEventListener("click", () => {
new WorkflowWizardModal(this.app, this.plugin, file).open();
});
// ── Steps list ─────────────────────────────────────────────────────
if (workflow.steps.length === 0) {
this.detailEl.createDiv({
cls: "duckmage-rt-empty",
text: "No steps. Click Edit to add steps.",
});
} else {
const stepsEl = this.detailEl.createDiv({ cls: "duckmage-wf-detail-steps" });
stepsEl.createEl("p", { text: "Steps", cls: "duckmage-rt-history-label" });
const list = stepsEl.createEl("ul");
list.style.margin = "0";
list.style.paddingLeft = "18px";
for (const step of workflow.steps) {
const li = list.createEl("li");
const tableName = step.tablePath.split("/").pop() ?? step.tablePath;
const label = step.label ? `${step.label}: ` : "";
li.createSpan({ text: `${label}${tableName} ×${step.rolls}` });
}
}
}
private renderTreeNodes(
container: HTMLElement,
nodes: TreeNode[],
@ -764,11 +1047,13 @@ export class RandomTableView extends ItemView {
.map(f => ({ result: f.basename, weight: 1 }));
const newEntries = [...kept, ...added];
const suffix = extractPostTableContent(content);
const rows = newEntries.map(e => `| ${e.result} | ${e.weight} |`).join("\n");
const updated = content.replace(
const replaced = content.replace(
/(\| Result \| Weight \|\n\|[-| ]+\|\n)([\s\S]*)$/,
`$1${rows}\n`,
);
const updated = suffix ? replaced.trimEnd() + "\n\n" + suffix : replaced;
if (updated !== content) await this.app.vault.modify(tableFile, updated);
}
@ -1001,6 +1286,41 @@ export class RandomTableView extends ItemView {
rollBtn.addEventListener("click", () => {
this.doRoll(table, resultBox, resultTextarea, historyEl, openNoteBtn);
});
// ── Used by workflows ───────────────────────────────────────────────
const tableKey = file.path.slice(0, -3); // remove .md
const usingWorkflows = this.workflowMap.get(tableKey) ?? [];
if (usingWorkflows.length > 0 || this.plugin.settings.workflowsFolder) {
const usedBySection = this.detailEl.createDiv({ cls: "duckmage-rt-used-by" });
usedBySection.createDiv({ text: "Workflows using this table", cls: "duckmage-rt-used-by-label" });
const linksEl = usedBySection.createDiv({ cls: "duckmage-rt-used-by-links" });
for (const wfPath of usingWorkflows) {
const wfFile = this.app.vault.getAbstractFileByPath(wfPath);
if (!(wfFile instanceof TFile)) continue;
const link = linksEl.createEl("a", {
text: wfFile.basename,
cls: "duckmage-rt-entry-link",
});
link.addEventListener("click", () => {
this.setViewMode("workflows");
// After mode switch, select the workflow
setTimeout(() => {
if (wfFile instanceof TFile) void this.loadWorkflow(wfFile);
}, 50);
});
}
const newWfLink = linksEl.createEl("a", {
text: "+ New workflow with this table",
cls: "duckmage-rt-entry-link",
});
newWfLink.style.fontStyle = "italic";
newWfLink.addEventListener("click", async () => {
this.setViewMode("workflows");
await this.createWorkflow(tableKey);
});
}
}
private doRoll(

433
src/WorkflowEditorModal.ts Normal file
View file

@ -0,0 +1,433 @@
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);
});
}
}

310
src/WorkflowWizardModal.ts Normal file
View file

@ -0,0 +1,310 @@
import { App, Modal, TFile, Notice } from "obsidian";
import type DuckmagePlugin from "./DuckmagePlugin";
import { normalizeFolder } from "./utils";
import { parseWorkflow, generateDefaultTemplate, stepPlaceholder, type Workflow } from "./workflow";
import { parseRandomTable, rollOnTable } from "./randomTable";
export class WorkflowWizardModal extends Modal {
private workflow!: Workflow;
// rolls[stepIdx][rollIdx] = result string or null (not yet rolled)
private rolls: (string | null)[][] = [];
private templateContent = "";
private stepEntries: string[][] = []; // [stepIdx] → display labels from the table
private resultTextarea!: HTMLTextAreaElement;
private saveNoteNameInput!: HTMLInputElement;
private saveResultsFolderInput!: HTMLInputElement;
private saveNoteBtn!: HTMLButtonElement;
private saveStatusEl!: HTMLElement;
private stepsArea!: HTMLElement;
constructor(
app: App,
private plugin: DuckmagePlugin,
private workflowFile: TFile,
) {
super(app);
}
async onOpen(): Promise<void> {
const rawContent = await this.app.vault.read(this.workflowFile);
this.workflow = parseWorkflow(rawContent, this.workflowFile.basename);
// Initialise rolls array
this.rolls = this.workflow.steps.map(step => Array(step.rolls).fill(null));
// Preload table entries for each step (for the manual-pick dropdown)
this.stepEntries = await Promise.all(
this.workflow.steps.map(async (step) => {
const tableFile = this.app.vault.getAbstractFileByPath(step.tablePath + ".md")
?? this.app.metadataCache.getFirstLinkpathDest(step.tablePath, this.workflowFile.path);
if (!(tableFile instanceof TFile)) return [];
const content = await this.app.vault.read(tableFile);
const table = parseRandomTable(content);
return table.entries.map(e =>
e.result.startsWith("[[")
? (e.result.slice(2, -2).split("/").pop() ?? e.result.slice(2, -2))
: e.result
);
})
);
// Load template
if (this.workflow.templateFile) {
const tmplFile = this.app.vault.getAbstractFileByPath(this.workflow.templateFile);
if (tmplFile instanceof TFile) {
this.templateContent = await this.app.vault.read(tmplFile);
}
}
if (!this.templateContent) {
this.templateContent = generateDefaultTemplate(this.workflow.steps);
}
this.titleEl.setText(this.workflowFile.basename);
this.buildUI();
}
private buildUI(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-wf-wizard");
// ── Header buttons ────────────────────────────────────────────────
const header = contentEl.createDiv({ cls: "duckmage-wf-wizard-header" });
const rollAllBtn = header.createEl("button", { text: "Roll all", cls: "mod-cta" });
rollAllBtn.title = "Roll all unfilled slots";
rollAllBtn.addEventListener("click", async () => {
await this.rollAll(false);
});
const rerollAllBtn = header.createEl("button", { text: "Reroll all" });
rerollAllBtn.title = "Reroll every slot";
rerollAllBtn.addEventListener("click", async () => {
await this.rollAll(true);
});
const copyResultBtn = header.createEl("button", { text: "Copy result" });
copyResultBtn.addEventListener("click", () => {
navigator.clipboard.writeText(this.resultTextarea?.value ?? "").then(() => {
copyResultBtn.setText("Copied!");
setTimeout(() => copyResultBtn.setText("Copy result"), 1500);
});
});
// ── Steps area ────────────────────────────────────────────────────
this.stepsArea = contentEl.createDiv({ cls: "duckmage-wf-wizard-steps" });
this.renderSteps();
// ── Result textarea ───────────────────────────────────────────────
contentEl.createEl("p", { text: "Result", cls: "duckmage-table-editor-heading" });
this.resultTextarea = contentEl.createEl("textarea", { cls: "duckmage-wf-template-area" });
this.resultTextarea.style.minHeight = "120px";
this.resultTextarea.readOnly = true;
this.resultTextarea.value = this.assembleResult();
// ── Save section ──────────────────────────────────────────────────
contentEl.createEl("p", { text: "Save as note", cls: "duckmage-table-editor-heading" });
const saveNoteNameRow = contentEl.createDiv({ cls: "duckmage-table-editor-name-row" });
saveNoteNameRow.createEl("label", { text: "Note name", cls: "duckmage-table-editor-name-label" });
this.saveNoteNameInput = saveNoteNameRow.createEl("input", {
type: "text",
cls: "duckmage-table-editor-name-input",
});
this.saveNoteNameInput.value = "";
const saveResultsFolderRow = contentEl.createDiv({ cls: "duckmage-table-editor-name-row" });
saveResultsFolderRow.createEl("label", { text: "Results folder", cls: "duckmage-table-editor-name-label" });
this.saveResultsFolderInput = saveResultsFolderRow.createEl("input", {
type: "text",
cls: "duckmage-table-editor-name-input",
});
this.saveResultsFolderInput.value = normalizeFolder(this.workflow.resultsFolder ?? "");
this.saveNoteBtn = contentEl.createEl("button", { text: "Save as note", cls: "mod-cta" });
this.saveStatusEl = contentEl.createDiv({ cls: "duckmage-wf-save-status" });
this.updateSaveButton();
this.saveNoteBtn.addEventListener("click", async () => {
await this.saveAsNote();
});
}
private renderSteps(): void {
this.stepsArea.empty();
for (let si = 0; si < this.workflow.steps.length; si++) {
const step = this.workflow.steps[si];
const stepEl = this.stepsArea.createDiv({ cls: "duckmage-wf-wizard-step" });
const stepHeader = stepEl.createDiv({ cls: "duckmage-wf-wizard-step-header" });
const tableName = step.tablePath.split("/").pop() ?? step.tablePath;
stepHeader.createEl("strong", { text: step.label || tableName });
stepHeader.createSpan({ cls: "duckmage-wf-roll-badge", text: `×${step.rolls}` });
for (let ri = 0; ri < step.rolls; ri++) {
const rollRow = stepEl.createDiv({ cls: "duckmage-wf-step-row" });
// Show the placeholder name as a label
const varLabel = rollRow.createEl("code", { cls: "duckmage-wf-placeholder-label" });
varLabel.setText(stepPlaceholder(step, ri));
const rollInput = rollRow.createEl("input", {
type: "text",
cls: "duckmage-wf-roll-input" + (this.rolls[si][ri] !== null ? " is-rolled" : ""),
});
rollInput.value = this.rolls[si][ri] ?? "";
rollInput.placeholder = "—";
rollInput.addEventListener("input", () => {
this.rolls[si][ri] = rollInput.value.trim() || null;
this.updateResultTextarea();
this.updateSaveButton();
});
const pickerSelect = rollRow.createEl("select", { cls: "duckmage-wf-pick-select" });
pickerSelect.createEl("option", { value: "", text: "— pick —" });
for (const label of (this.stepEntries[si] ?? [])) {
pickerSelect.createEl("option", { value: label, text: label });
}
pickerSelect.addEventListener("change", () => {
const picked = pickerSelect.value;
if (!picked) return;
this.rolls[si][ri] = picked;
rollInput.value = picked;
rollInput.classList.add("is-rolled");
rollBtn.setText("Reroll");
pickerSelect.value = "";
this.updateResultTextarea();
this.updateSaveButton();
});
const rollBtn = rollRow.createEl("button", {
text: this.rolls[si][ri] !== null ? "Reroll" : "Roll",
});
rollBtn.addEventListener("click", async () => {
rollBtn.disabled = true;
rollBtn.setText("…");
await this.rollStep(si, ri);
rollInput.value = this.rolls[si][ri] ?? "";
rollInput.classList.toggle("is-rolled", this.rolls[si][ri] !== null);
rollBtn.setText("Reroll");
rollBtn.disabled = false;
this.updateResultTextarea();
this.updateSaveButton();
});
}
}
}
private async rollStep(stepIdx: number, rollIdx: number): Promise<void> {
const step = this.workflow.steps[stepIdx];
// Try exact path first, then Obsidian link resolution with workflow file as source
const tableFile = this.app.vault.getAbstractFileByPath(step.tablePath + ".md")
?? this.app.metadataCache.getFirstLinkpathDest(step.tablePath, this.workflowFile.path);
if (!(tableFile instanceof TFile)) {
new Notice(`Table not found: ${step.tablePath}`);
return;
}
const content = await this.app.vault.read(tableFile);
const table = parseRandomTable(content);
const entry = rollOnTable(table);
if (!entry) return;
const displayLabel = entry.isLink
? (entry.result.split("/").pop() ?? entry.result)
: entry.result;
this.rolls[stepIdx][rollIdx] = displayLabel;
}
/** Roll all unfilled slots (rerollAll=false) or every slot (rerollAll=true). */
private async rollAll(rerollAll: boolean): Promise<void> {
for (let si = 0; si < this.workflow.steps.length; si++) {
for (let ri = 0; ri < this.workflow.steps[si].rolls; ri++) {
if (rerollAll || this.rolls[si][ri] === null) {
await this.rollStep(si, ri);
}
}
}
this.renderSteps();
this.updateResultTextarea();
this.updateSaveButton();
}
private assembleResult(): string {
let result = this.templateContent;
for (let si = 0; si < this.workflow.steps.length; si++) {
const step = this.workflow.steps[si];
for (let ri = 0; ri < step.rolls; ri++) {
const placeholder = stepPlaceholder(step, ri);
const value = this.rolls[si][ri];
// Replace all occurrences of this placeholder
const escaped = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
result = result.replace(new RegExp(escaped, "g"),
value !== null ? value : `[${placeholder}]`);
}
}
return result;
}
private updateResultTextarea(): void {
if (this.resultTextarea) {
this.resultTextarea.value = this.assembleResult();
}
}
private allRolled(): boolean {
return this.rolls.every(stepRolls => stepRolls.every(r => r !== null));
}
private updateSaveButton(): void {
if (this.saveNoteBtn) {
this.saveNoteBtn.disabled = !this.allRolled();
}
}
private defaultNoteName(): string {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}${pad(now.getMinutes())}`;
return `${this.workflowFile.basename} ${ts}`;
}
private async saveAsNote(): Promise<void> {
const noteName = this.saveNoteNameInput.value.trim();
if (!noteName) {
this.saveStatusEl.style.color = "var(--color-red)";
this.saveStatusEl.setText("Note name is required.");
return;
}
const folder = normalizeFolder(this.saveResultsFolderInput.value.trim());
const notePath = folder ? `${folder}/${noteName}.md` : `${noteName}.md`;
if (folder && !this.app.vault.getAbstractFileByPath(folder)) {
try { await this.app.vault.createFolder(folder); } catch { /* may already exist */ }
}
const noteContent = this.resultTextarea.value;
try {
let savedFile: TFile;
const existing = this.app.vault.getAbstractFileByPath(notePath);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, noteContent);
savedFile = existing;
} else {
savedFile = await this.app.vault.create(notePath, noteContent);
}
this.close();
await this.app.workspace.getLeaf("tab").openFile(savedFile);
} catch (err) {
this.saveStatusEl.style.color = "var(--color-red)";
this.saveStatusEl.setText(`Error: ${err}`);
}
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -126,4 +126,5 @@ export const DEFAULT_SETTINGS: DuckmagePluginSettings = {
rollTableExcludedFolders: ["terrain"],
encounterTableExcludedFolders: ["terrain"],
defaultRegion: "default",
workflowsFolder: "",
};

View file

@ -24,11 +24,9 @@ The visible standout feature — spire, ruin, lighthouse, statue, village — th
### Towns
---
### Dungeons
---
### Features
---

View file

@ -72,6 +72,24 @@ export function parseRandomTable(content: string): RandomTable {
return { dice, entries, linkedFolder, description };
}
/**
* Return any content that appears after the markdown table in a random table file.
* Finds the last line starting with `|`, then returns everything after it (leading
* blank lines stripped). Returns "" if nothing follows the table.
*/
export function extractPostTableContent(content: string): string {
const tableHeaderMatch = /\| Result \| Weight \|/m.exec(content);
if (!tableHeaderMatch) return "";
const afterHeader = content.slice(tableHeaderMatch.index);
const lines = afterHeader.split("\n");
let lastPipeIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trimStart().startsWith("|")) lastPipeIdx = i;
}
if (lastPipeIdx === -1) return "";
return lines.slice(lastPipeIdx + 1).join("\n").replace(/^\n+/, "");
}
/** Weighted random selection. Returns a random entry. */
export function rollOnTable(table: RandomTable): RandomTableEntry | null {
if (table.entries.length === 0) return null;

View file

@ -39,6 +39,7 @@ export interface DuckmagePluginSettings {
rollTableExcludedFolders: string[];
encounterTableExcludedFolders: string[];
defaultRegion: string;
workflowsFolder: string;
}
export const LINK_SECTIONS = ["Towns", "Dungeons", "Features", "Quests", "Factions", "Encounters Table"] as const;

126
src/workflow.ts Normal file
View file

@ -0,0 +1,126 @@
export interface WorkflowStep {
tablePath: string; // vault-relative path, no .md extension
rolls: number; // >= 1
label?: string; // optional display name
}
export interface Workflow {
name: string;
resultsFolder?: string;
templateFile?: string;
steps: WorkflowStep[];
}
/** Parse a workflow markdown file into a Workflow. */
export function parseWorkflow(content: string, name: string): Workflow {
const workflow: Workflow = { name, steps: [] };
// Extract YAML frontmatter
const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
if (fmMatch) {
const fm = fmMatch[1];
const rfMatch = /^results-folder:\s*(.+)$/m.exec(fm);
if (rfMatch) workflow.resultsFolder = rfMatch[1].trim();
const tfMatch = /^template-file:\s*(.+)$/m.exec(fm);
if (tfMatch) workflow.templateFile = tfMatch[1].trim();
}
// Find the workflow steps table (| Table | Rolls | Label |)
const tableMatch = /\|\s*Table\s*\|\s*Rolls\s*\|\s*Label\s*\|/i.exec(content);
if (!tableMatch) return workflow;
const afterHeader = content.slice(tableMatch.index);
const lines = afterHeader.split("\n");
// Skip header line and separator line
for (let i = 2; i < lines.length; i++) {
const line = lines[i].trim();
if (!line.startsWith("|")) break;
const cols = line.split("|").map(c => c.trim()).filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
if (cols.length < 2) continue;
const rawTable = cols[0];
const rolls = parseInt(cols[1], 10);
const label = cols[2] ?? "";
if (!rawTable || isNaN(rolls) || rolls < 1) continue;
// Extract path from [[link]] syntax or plain text
const linkMatch = /\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/.exec(rawTable);
const tablePath = linkMatch ? linkMatch[1].trim() : rawTable.trim();
workflow.steps.push({
tablePath,
rolls,
label: label || undefined,
});
}
return workflow;
}
/** Serialize a Workflow back to markdown. */
export function buildWorkflowContent(workflow: Workflow): string {
const lines: string[] = [];
// Frontmatter
lines.push("---");
if (workflow.resultsFolder) lines.push(`results-folder: ${workflow.resultsFolder}`);
if (workflow.templateFile) lines.push(`template-file: ${workflow.templateFile}`);
lines.push("---");
lines.push("");
// Steps table
lines.push("| Table | Rolls | Label |");
lines.push("|-------|-------|-------|");
for (const step of workflow.steps) {
const tableCell = `[[${step.tablePath}]]`;
const label = step.label ?? "";
lines.push(`| ${tableCell} | ${step.rolls} | ${label} |`);
}
return lines.join("\n") + "\n";
}
/** Derive the variable base name for a step: label if set, else table basename with spaces→underscores. */
export function stepVarName(step: WorkflowStep): string {
const base = step.label || (step.tablePath.split("/").pop() ?? step.tablePath);
return base.replace(/ /g, "_");
}
/** Return the placeholder string for a specific roll of a step.
* Single-roll step: `$label` Multi-roll step: `$label_1`, `$label_2`, */
export function stepPlaceholder(step: WorkflowStep, rollIndex: number): string {
const varName = stepVarName(step);
return step.rolls === 1 ? `$${varName}` : `$${varName}_${rollIndex + 1}`;
}
/** Return an auto-generated template body with label-based placeholders per step. */
export function generateDefaultTemplate(steps: WorkflowStep[]): string {
const lines: string[] = [];
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const heading = step.label || `Table ${i + 1}`;
lines.push(`## ${heading}`);
lines.push("");
for (let r = 0; r < step.rolls; r++) {
lines.push(stepPlaceholder(step, r));
}
lines.push("");
}
return lines.join("\n");
}
/** Return the list of all required placeholder strings for the given steps. */
export function requiredPlaceholders(steps: WorkflowStep[]): string[] {
const placeholders: string[] = [];
for (const step of steps) {
for (let r = 0; r < step.rolls; r++) {
placeholders.push(stepPlaceholder(step, r));
}
}
return placeholders;
}

View file

@ -2440,3 +2440,166 @@ tr:hover .duckmage-rt-entry-copy-btn:hover {
.duckmage-saving-indicator.is-active {
opacity: 1;
}
/* ── Workflow styles ──────────────────────────────────────────────────────── */
/* View-mode toggle */
.duckmage-rt-mode-tabs {
display: flex;
gap: 4px;
padding: 4px 8px;
border-bottom: 1px solid var(--background-modifier-border);
}
.duckmage-rt-mode-tab {
background: none;
border: 1px solid transparent;
border-radius: 4px;
padding: 2px 10px;
font-size: 0.85em;
color: var(--text-muted);
cursor: pointer;
box-shadow: none;
}
.duckmage-rt-mode-tab.is-active {
border-color: var(--background-modifier-border);
color: var(--text-normal);
background: var(--background-secondary);
}
/* Workflow list item */
.duckmage-rt-workflow-item {
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
}
.duckmage-rt-workflow-item:hover {
background: var(--background-modifier-hover);
}
.duckmage-rt-workflow-item.is-active {
background: var(--background-modifier-active-hover);
font-weight: 600;
}
/* Workflow wizard step rows */
.duckmage-wf-wizard-header {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.duckmage-wf-wizard-step {
margin-bottom: 12px;
padding: 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
}
.duckmage-wf-wizard-step-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.duckmage-wf-roll-badge {
font-size: 0.75em;
background: var(--background-modifier-border);
border-radius: 3px;
padding: 1px 5px;
color: var(--text-muted);
}
.duckmage-wf-step-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.duckmage-wf-roll-input {
flex: 1;
font-style: italic;
color: var(--text-muted);
}
.duckmage-wf-pick-select {
flex: 1;
min-width: 0;
}
.duckmage-wf-roll-input.is-rolled {
font-style: normal;
color: var(--text-normal);
}
/* Workflow template area */
.duckmage-wf-template-area {
width: 100%;
min-height: 140px;
font-family: var(--font-monospace);
font-size: 0.85em;
resize: vertical;
box-sizing: border-box;
}
/* Validation */
.duckmage-wf-validation-ok {
color: var(--color-green);
font-size: 0.85em;
margin-top: 4px;
}
.duckmage-wf-validation-err {
color: var(--color-red);
font-size: 0.85em;
margin-top: 4px;
}
/* Workflow editor step rows */
.duckmage-wf-editor-steps {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.duckmage-wf-editor-step-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.duckmage-wf-editor-step-row select {
flex: 2;
}
.duckmage-wf-editor-step-row input[type="number"] {
width: 52px;
}
.duckmage-wf-editor-step-row input[type="text"] {
flex: 1;
}
/* "Used by workflows" section in table detail */
.duckmage-rt-used-by {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid var(--background-modifier-border);
}
.duckmage-rt-used-by-label {
font-size: 0.8em;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 4px;
}
.duckmage-rt-used-by-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
/* Save status */
.duckmage-wf-save-status {
margin-top: 4px;
font-size: 0.85em;
}
.duckmage-wf-editor-add-area {
margin-bottom: 8px;
}
.duckmage-wf-editor-add-btn-row {
margin-top: 6px;
}

View file

@ -5,6 +5,7 @@ import {
getOddsLabel,
getDieRanges,
setDiceInFrontmatter,
extractPostTableContent,
} from "../src/randomTable";
// ── parseRandomTable ──────────────────────────────────────────────────────────
@ -309,3 +310,22 @@ describe("setDiceInFrontmatter", () => {
expect(result).toContain("| A | 1 |");
});
});
// ── extractPostTableContent ───────────────────────────────────────────────────
describe("extractPostTableContent", () => {
it("returns empty string when nothing follows the table", () => {
const content = `---\ndice: 6\n---\n\n| Result | Weight |\n|--------|--------|\n| a | 1 |\n`;
expect(extractPostTableContent(content)).toBe("");
});
it("returns content after the last table row", () => {
const content = `| Result | Weight |\n|--------|--------|\n| a | 1 |\n\n## Notes\n\nSome extra text.\n`;
expect(extractPostTableContent(content)).toBe("## Notes\n\nSome extra text.\n");
});
it("returns empty string when no table present", () => {
expect(extractPostTableContent("No table here.")).toBe("");
});
});