diff --git a/src/DuckmageSettingTab.ts b/src/DuckmageSettingTab.ts index a61c3aa..403191b 100644 --- a/src/DuckmageSettingTab.ts +++ b/src/DuckmageSettingTab.ts @@ -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.).") diff --git a/src/RandomTableEditorModal.ts b/src/RandomTableEditorModal.ts index e237d45..aaff41e 100644 --- a/src/RandomTableEditorModal.ts +++ b/src/RandomTableEditorModal.ts @@ -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; } } diff --git a/src/RandomTableView.ts b/src/RandomTableView.ts index a72c57f..edcf290 100644 --- a/src/RandomTableView.ts +++ b/src/RandomTableView.ts @@ -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 = new Map(); // folder path → table file path + private workflowMap: Map = 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 { + 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 { + 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 { + this.activeFile = file; + this.listEl?.querySelectorAll(".duckmage-rt-workflow-item") + .forEach(el => el.toggleClass("is-active", el.title === file.path)); + await this.renderWorkflowDetail(); + } + + private async renderWorkflowDetail(): Promise { + 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( diff --git a/src/WorkflowEditorModal.ts b/src/WorkflowEditorModal.ts new file mode 100644 index 0000000..af8ec25 --- /dev/null +++ b/src/WorkflowEditorModal.ts @@ -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) | null = null; + private dragInitialized = false; + + constructor( + app: App, + private plugin: DuckmagePlugin, + private file: TFile, + private onSaved?: () => void, + ) { + super(app); + } + + async onOpen(): Promise { + 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(".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); + }); + } +} diff --git a/src/WorkflowWizardModal.ts b/src/WorkflowWizardModal.ts new file mode 100644 index 0000000..407ace9 --- /dev/null +++ b/src/WorkflowWizardModal.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/src/constants.ts b/src/constants.ts index 6291b53..292ef9f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -126,4 +126,5 @@ export const DEFAULT_SETTINGS: DuckmagePluginSettings = { rollTableExcludedFolders: ["terrain"], encounterTableExcludedFolders: ["terrain"], defaultRegion: "default", + workflowsFolder: "", }; diff --git a/src/defaultHexTemplate.md b/src/defaultHexTemplate.md index 80187fc..b4c7212 100644 --- a/src/defaultHexTemplate.md +++ b/src/defaultHexTemplate.md @@ -24,11 +24,9 @@ The visible standout feature — spire, ruin, lighthouse, statue, village — th ### Towns --- - ### Dungeons --- - ### Features --- diff --git a/src/randomTable.ts b/src/randomTable.ts index 56402ec..65e3e7f 100644 --- a/src/randomTable.ts +++ b/src/randomTable.ts @@ -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; diff --git a/src/types.ts b/src/types.ts index 4b35649..e5a004b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/src/workflow.ts b/src/workflow.ts new file mode 100644 index 0000000..24b8f92 --- /dev/null +++ b/src/workflow.ts @@ -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; +} diff --git a/styles.css b/styles.css index d504d1a..a4f7410 100644 --- a/styles.css +++ b/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; +} diff --git a/tests/randomTable.test.ts b/tests/randomTable.test.ts index 98e998b..3b400d0 100644 --- a/tests/randomTable.test.ts +++ b/tests/randomTable.test.ts @@ -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(""); + }); +});