mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 14:30:24 +00:00
create workflow tool
This commit is contained in:
parent
2ebee21308
commit
583c3f656e
12 changed files with 1426 additions and 19 deletions
|
|
@ -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.).")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
433
src/WorkflowEditorModal.ts
Normal 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
310
src/WorkflowWizardModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -126,4 +126,5 @@ export const DEFAULT_SETTINGS: DuckmagePluginSettings = {
|
|||
rollTableExcludedFolders: ["terrain"],
|
||||
encounterTableExcludedFolders: ["terrain"],
|
||||
defaultRegion: "default",
|
||||
workflowsFolder: "",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,11 +24,9 @@ The visible standout feature — spire, ruin, lighthouse, statue, village — th
|
|||
### Towns
|
||||
|
||||
---
|
||||
|
||||
### Dungeons
|
||||
|
||||
---
|
||||
|
||||
### Features
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
126
src/workflow.ts
Normal 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;
|
||||
}
|
||||
163
styles.css
163
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue