auto update links in the random tables

This commit is contained in:
isaprettycoolguy@protonmail.com 2026-04-04 11:51:53 -04:00
parent f471eee8f0
commit 37c4a866bc
41 changed files with 7421 additions and 7318 deletions

View file

@ -1,16 +1,16 @@
---
description: Start the duckmage plugin dev watcher (esbuild watch mode)
allowed-tools: Bash(npm:*)
---
Start esbuild in watch mode for the duckmage plugin. This rebuilds `main.js` automatically on every TypeScript file save.
```
cd /mnt/c/Users/markr/Documents/KB/journal/.obsidian/plugins/duckmage-plugin && npm run dev
```
Run this in the background — it is a long-running process. Tell the user the watcher has started, then remind them:
- `main.js` rebuilds automatically on every save to `main.ts`
- To reload the plugin in Obsidian after a rebuild, run:
`powershell.exe -Command "obsidian plugin:reload id=duckmage-plugin"`
- Or use `/rebuild` instead for a one-off production build (includes type-checking).
---
description: Start the duckmage plugin dev watcher (esbuild watch mode)
allowed-tools: Bash(npm:*)
---
Start esbuild in watch mode for the duckmage plugin. This rebuilds `main.js` automatically on every TypeScript file save.
```
cd /mnt/c/Users/markr/Documents/KB/journal/.obsidian/plugins/duckmage-plugin && npm run dev
```
Run this in the background — it is a long-running process. Tell the user the watcher has started, then remind them:
- `main.js` rebuilds automatically on every save to `main.ts`
- To reload the plugin in Obsidian after a rebuild, run:
`powershell.exe -Command "obsidian plugin:reload id=duckmage-plugin"`
- Or use `/rebuild` instead for a one-off production build (includes type-checking).

View file

@ -1,63 +1,63 @@
---
description: Release a new version of the plugin. Follow these steps exactly:
allowed-tools: Bash(npm:*), Bash(git:*)
---
Release a new version of the plugin. Follow these steps exactly:
## 1. Check current version
Read `manifest.json` to confirm the current version number, then confirm with the user what the new version should be (patch / minor / major).
**Important**: `package.json` and `manifest.json` must stay in sync. Set `package.json` to the new version first:
```bash
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = 'X.Y.Z';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, '\t'));
console.log('package.json set to', pkg.version);
"
```
## 2. Bump version
```bash
npm run version
```
This reads the version from `package.json` and writes it into `manifest.json` and `versions.json`, then stages both. Confirm by reading `manifest.json`.
## 3. Build and test
```bash
npm run build && npm test
```
Do not proceed if either fails.
## 4. Commit and push to master
```bash
git add manifest.json versions.json package.json package-lock.json
git commit -m "chore: release X.Y.Z"
git push prod master
```
Replace `X.Y.Z` with the actual version from `manifest.json`.
That's it. Pushing to `master` automatically triggers the GitHub Actions release workflow, which will:
- Detect the new version in `manifest.json`
- Run tests and build `main.js`
- Create a git tag `X.Y.Z`
- Publish a GitHub release with auto-generated changelog and `main.js`, `manifest.json`, `styles.css` attached
---
**Notes:**
- The remote is named `prod` (not `origin`)
- `main.js` is in `.gitignore` — never commit it manually; the CI builds it
- No manual tagging required — the workflow creates the tag automatically
- The release is published immediately (not a draft) with auto-generated release notes
- If you push to master without changing the version, no release is created (idempotent)
---
description: Release a new version of the plugin. Follow these steps exactly:
allowed-tools: Bash(npm:*), Bash(git:*)
---
Release a new version of the plugin. Follow these steps exactly:
## 1. Check current version
Read `manifest.json` to confirm the current version number, then confirm with the user what the new version should be (patch / minor / major).
**Important**: `package.json` and `manifest.json` must stay in sync. Set `package.json` to the new version first:
```bash
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = 'X.Y.Z';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, '\t'));
console.log('package.json set to', pkg.version);
"
```
## 2. Bump version
```bash
npm run version
```
This reads the version from `package.json` and writes it into `manifest.json` and `versions.json`, then stages both. Confirm by reading `manifest.json`.
## 3. Build and test
```bash
npm run build && npm test
```
Do not proceed if either fails.
## 4. Commit and push to master
```bash
git add manifest.json versions.json package.json package-lock.json
git commit -m "chore: release X.Y.Z"
git push prod master
```
Replace `X.Y.Z` with the actual version from `manifest.json`.
That's it. Pushing to `master` automatically triggers the GitHub Actions release workflow, which will:
- Detect the new version in `manifest.json`
- Run tests and build `main.js`
- Create a git tag `X.Y.Z`
- Publish a GitHub release with auto-generated changelog and `main.js`, `manifest.json`, `styles.css` attached
---
**Notes:**
- The remote is named `prod` (not `origin`)
- `main.js` is in `.gitignore` — never commit it manually; the CI builds it
- No manual tagging required — the workflow creates the tag automatically
- The release is published immediately (not a draft) with auto-generated release notes
- If you push to master without changing the version, no release is created (idempotent)

View file

@ -1,28 +1,28 @@
name: CI
on:
push:
branches:
- "**"
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build & type-check
run: npm run build
- name: Run tests
run: npm test
name: CI
on:
push:
branches:
- "**"
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build & type-check
run: npm run build
- name: Run tests
run: npm test

View file

@ -1,65 +1,65 @@
name: Release Obsidian plugin
on:
push:
branches:
- master
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Read version from manifest
id: version
run: echo "version=$(node -p "require('./manifest.json').version")" >> $GITHUB_OUTPUT
- name: Check if release already exists
id: check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "${{ steps.version.outputs.version }}" &>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Use Node.js
if: steps.check.outputs.exists == 'false'
uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm"
- name: Install dependencies
if: steps.check.outputs.exists == 'false'
run: npm ci
- name: Run tests
if: steps.check.outputs.exists == 'false'
run: npm test
- name: Build plugin
if: steps.check.outputs.exists == 'false'
run: npm run build
- name: Tag and create release
if: steps.check.outputs.exists == 'false'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version="${{ steps.version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$version" -m "$version"
git push origin "$version"
gh release create "$version" \
--title="$version" \
--generate-notes \
main.js manifest.json styles.css
name: Release Obsidian plugin
on:
push:
branches:
- master
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Read version from manifest
id: version
run: echo "version=$(node -p "require('./manifest.json').version")" >> $GITHUB_OUTPUT
- name: Check if release already exists
id: check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "${{ steps.version.outputs.version }}" &>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Use Node.js
if: steps.check.outputs.exists == 'false'
uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm"
- name: Install dependencies
if: steps.check.outputs.exists == 'false'
run: npm ci
- name: Run tests
if: steps.check.outputs.exists == 'false'
run: npm test
- name: Build plugin
if: steps.check.outputs.exists == 'false'
run: npm run build
- name: Tag and create release
if: steps.check.outputs.exists == 'false'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version="${{ steps.version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$version" -m "$version"
git push origin "$version"
gh release create "$version" \
--title="$version" \
--generate-notes \
main.js manifest.json styles.css

18
.gitignore vendored
View file

@ -1,9 +1,9 @@
node_modules
main.js
main.js.map
.DS_Store
*.log
data.json
.claude/settings.local.json
.claude/worktrees/
.claude/worktrees/*
node_modules
main.js
main.js.map
.DS_Store
*.log
data.json
.claude/settings.local.json
.claude/worktrees/
.claude/worktrees/*

376
CLAUDE.md
View file

@ -1,188 +1,188 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development (watch mode with inline sourcemaps) — use with Hot Reload (see below)
npm run dev
# Production build (type-checks then bundles)
npm run build
# Run tests (vitest)
npm test
# Bump version (updates manifest.json and versions.json, stages both)
npm run version
```
Slash commands (invoke inside Claude Code):
- `/dev` — starts esbuild in watch mode (long-running; pairs with Hot Reload)
- `/rebuild` — one-off production build + test run; reports errors if either fails
**Always run `/rebuild` after making code changes.** The TypeScript type-check and test suite are the only automated verification — catch errors before the user does.
## Dev loop
Each coding session:
1. Run `npm run dev` in a terminal (or `/dev` from Claude Code) — esbuild watches for changes and rebuilds `main.js` on every save
2. After a rebuild, reload the plugin in Obsidian:
```bash
powershell.exe -Command "obsidian plugin:reload id=duckmage-plugin"
```
3. Use `/rebuild` for a final production build before committing (runs the TypeScript type-check and tests too)
## Architecture
The plugin source is split across `main.ts` (entry point re-export) and modules under `src/`. esbuild bundles everything into `main.js`, which Obsidian loads directly.
See `ARCHITECTURE.md` for a full overview of the system.
### Source layout
```
main.ts ← thin re-export: export { default } from "./src/DuckmagePlugin"
src/
DuckmagePlugin.ts ← Plugin class (entry point, default export)
HexMapView.ts ← ItemView — hex grid, all drawing tools, inline modals
HexEditorModal.ts ← Modal — right-click per-hex editor (terrain, links, notes, icon override)
HexTableView.ts ← ItemView — spreadsheet view of all hex notes with filters/sort
TerrainPickerModal.ts ← Modal — full terrain palette picker for the terrain paint tool
TerrainEntryEditorModal.ts ← Modal — edit a single terrain palette entry (name, color, icon)
IconPickerModal.ts ← Modal — icon picker for the icon paint tool
RegionModal.ts ← Modal — switch active region, create/rename/delete regions
FileLinkSuggestModal.ts ← SuggestModal — file picker scoped to worldFolder
RandomTableView.ts ← ItemView — random table + workflow browser (tabbed: Tables / Workflows)
RandomTableModal.ts ← Modal — inline roll modal used from HexEditorModal
RandomTableEditorModal.ts ← Modal — edit entries of a random table file
WorkflowEditorModal.ts ← Modal — edit workflow definition (steps, template, results folder)
WorkflowWizardModal.ts ← Modal — execute a workflow (roll steps, fill template, save as note)
randomTable.ts ← Pure logic: parse, roll, weight, die-range helpers
workflow.ts ← Pure logic: parse/serialize workflows, generate templates, placeholder helpers
DuckmageSettingTab.ts ← PluginSettingTab — settings UI
types.ts ← Interfaces & type constants (TerrainColor, DuckmagePluginSettings, LINK_SECTIONS, TEXT_SECTIONS)
constants.ts ← Runtime constants (VIEW_TYPE_*, DEFAULT_TERRAIN_PALETTE, DEFAULT_SETTINGS)
defaultHexTemplate.md ← Built-in hex note template (imported as text via esbuild loader)
frontmatter.ts ← YAML frontmatter helpers (terrain + icon override read/write)
sections.ts ← Markdown section helpers (addLinkToSection, getLinksInSection, getAllSectionData, …)
utils.ts ← Shared utilities (normalizeFolder, getIconUrl, makeTableTemplate)
md.d.ts ← TypeScript declaration for "*.md" text imports
```
The `.md` loader is configured in `esbuild.config.mjs` (`loader: { '.md': 'text' }`), allowing `defaultHexTemplate.md` to be imported as a plain string.
### Plugin purpose
Renders an interactive hex-grid map for tabletop RPG world-building inside Obsidian. Each hex cell corresponds to a Markdown note on disk. The map supports terrain painting, icon painting, road/river chain drawing, link-to-hex tools (random tables, factions), panning, and zooming. A spreadsheet view summarises all hex notes with filtering. A random tables view lets users browse, roll, and edit weighted random tables, and execute multi-step workflows that chain table rolls into a filled template note.
### Key classes
- **`DuckmagePlugin`** (`src/DuckmagePlugin.ts`) — Main plugin entry point. Registers views (`HexMapView`, `HexTableView`, `RandomTableView`), ribbon icons, commands, and the settings tab. Key public API:
- `hexPath(x, y)` — vault-relative path for a hex note
- `createHexNote(x, y)` — creates hex note from template
- `loadAvailableIcons()` — merges plugin `icons/` with user custom icons folder into `availableIcons: string[]`
- `refreshHexMap()` — re-renders all open `HexMapView` instances
- `ensureTerrainTables()` — creates missing terrain table files under `tablesFolder/terrain/`
- `ensureAllRollerLinks()` — adds roller-link preamble to all table files
- `backfillTerrainLinks()` — links each hex note's terrain table into its Encounters Table section
- `buildRollerLink(path)` — generates the `[Roll](<obsidian://…>)` URI for a table file
- **`HexMapView`** (`src/HexMapView.ts`, extends `ItemView`) — Renders the hex grid and handles all user interaction.
- **DOM structure**: `contentEl` (`.duckmage-hex-map-container`) → `.duckmage-hex-map-clip` (panning viewport, `overflow: hidden`) + `.duckmage-hex-map-controls` (overlay for buttons, `pointer-events: none`). This keeps toolbar/expand buttons unclipped by the viewport transform.
- **`renderGrid(terrainOverrides?, iconOverrides?)`** — full DOM re-render. Optional override Maps allow passing values not yet in the metadata cache.
- **Drawing tools**: `drawingMode` union `"road" | "river" | "terrain" | "icon" | "tableLink" | "factionLink" | null`. Toolbar buttons toggle mode; each mode has a `handle*Button()` opener, `exit*Mode()` closer, and `onHex*Click()` handler.
- **Write queues**: `scheduleTerrainWrite` / `scheduleIconWrite` coalesce rapid repaints — only the latest value per hex is written, preventing stale overwrites.
- **Link tools** (`tableLink`, `factionLink`): pick a file via folder-tree modal, then click hexes to add a wiki-link to the `Encounters Table` or `Factions` section. Visual feedback: badge span + CSS ripple blip animation (`duckmage-hex-blip`).
- Left-click: opens/creates hex note (normal), paints terrain/icon, extends road/river chain, or adds a link.
- Right-click: opens `HexEditorModal` (normal), deletes road/river node, or exits current tool mode.
- Expand buttons (+) grow the grid in four cardinal directions, adjusting `gridOffset` and `gridSize` in settings.
- **`centerOnHex(x, y)`** (public) — pans the viewport to centre on a given hex coordinate.
- **`activeRegionName`** (public) — the currently displayed region name.
- **`HexEditorModal`** (`src/HexEditorModal.ts`, extends `Modal`) — The right-click per-hex editor:
- Terrain picker (2-row scrollable grid) with icon override and "Clear terrain".
- Dropdown link sections for **Towns, Dungeons, Features, Quests, Factions, Encounters Table** — each backed by its own configured folder, rendered via `renderDropdownSection`. Uses `LinkPickerModal` internally (file list + create-new).
- Free-text note sections: Description, Landmark, Hidden, Secret, Weather, Hooks & Rumors.
- Fetches all section data in a single read before touching the DOM (`getAllSectionData`).
- **`HexTableView`** (`src/HexTableView.ts`, extends `ItemView`) — Spreadsheet of all hex notes:
- Columns: Hex (coords + jump button), Terrain, Description, Landmark, Towns, Dungeons, Features, Quests, Factions, Enc. Table, Hidden, Secret, Weather, Hooks & Rumors.
- Enc. Table cell shows the basename of the linked table file; clicking opens `RandomTableView` at that file.
- Toolbar filters: X/Y range inputs, terrain multi-select (left-click include / right-click exclude), Has Town/Dungeon/Feature/Quest/Faction checkboxes, region selector, X→Y / Y→X sort priority, Asc/Desc direction.
- Live updates on vault `modify` events (300 ms debounce per file).
- Jump button (◎) calls `HexMapView.centerOnHex(x, y)` on the open map view.
- **`RandomTableView`** (`src/RandomTableView.ts`, extends `ItemView`) — Tabbed browser with two modes:
- **Tables mode**: folder tree + search + new-table creator. Detail pane shows entries with die-range and % odds, die-size selector, Roll button, copy result, roll history. Edit opens `RandomTableEditorModal`. Bottom of detail pane shows "Workflows using this table" links (each opens that workflow in Workflows mode) and a "+ New workflow with this table" link.
- **Workflows mode**: folder tree of workflow files. Detail pane shows each step as a clickable link (opens that table in Tables mode) with roll count. Edit opens `WorkflowEditorModal`. "Roll workflow" button opens `WorkflowWizardModal`.
- **`openTable(filePath)`** (public) — switches to Tables mode, expands ancestor folders so the target is visible, and loads the table detail; called from `HexTableView` (Enc. Table cell), `HexEditorModal`, workflow step links, and the protocol handler.
- **`RandomTableModal`** (`src/RandomTableModal.ts`, extends `Modal`) — Lightweight inline roll modal opened from `HexEditorModal` (🎲 button per link section or 📖 for terrain description). Skips the picker if `initialFilePath` is supplied.
- **`RandomTableEditorModal`** (`src/RandomTableEditorModal.ts`, extends `Modal`) — Edit table entries (result text + weight), linked folder, description, filter flags. Auto-saves on close. Accepts optional `initialContent` to avoid a redundant vault read when the caller already has the file content.
- **`WorkflowEditorModal`** (`src/WorkflowEditorModal.ts`, extends `Modal`) — Edit a workflow definition:
- Rename workflow, set results folder, manage steps (table picker + roll count + label) with drag-to-reorder.
- Template textarea with live placeholder validation — shows missing `$placeholder` names.
- Template auto-syncs: new step appends `## label\n$label` block; label change updates the heading AND all `$var`/`$var_N` placeholder references; roll-count change adds or removes `_N` suffixed placeholders (reducing to 1 collapses back to bare `$var`).
- Auto-saves workflow file and template file on close. Modal is draggable by its title bar.
- **`WorkflowWizardModal`** (`src/WorkflowWizardModal.ts`, extends `Modal`) — Execute a workflow:
- Shows each step with a manual-pick dropdown and a Roll button. Fixed-width controls to prevent layout shift on roll.
- Result textarea shows the template filled with rolled values.
- Save section writes the filled result as a new vault note in the configured results folder.
- **`RegionModal`** (`src/RegionModal.ts`, extends `Modal`) — Manage hex map regions:
- Switch the active region displayed on the hex map.
- Create, rename, and delete regions. Regions map to subfolders under `hexFolder`.
- **`TerrainEntryEditorModal`** (`src/TerrainEntryEditorModal.ts`, extends `Modal`) — Edit a single terrain palette entry: name, color, icon (with preview), and icon color tint. Changes are staged in pending fields and only written to the palette on Save.
- **`TerrainPickerModal`** (`src/TerrainPickerModal.ts`) — Full terrain palette picker (no row cap). Includes "Clear" to erase terrain.
- **`IconPickerModal`** (`src/IconPickerModal.ts`) — Full icon picker with image previews. Includes "Remove" option.
- **`FileLinkSuggestModal`** (`src/FileLinkSuggestModal.ts`) — Reusable fuzzy-search file picker scoped to a configured folder.
- **`DuckmageSettingTab`** (`src/DuckmageSettingTab.ts`) — Settings UI:
- "Generate folders" button — fills blank folder settings with defaults under `worldFolder` and creates the folders.
- Folder paths: world, hexes, towns, dungeons, quests, features, factions, tables, workflows.
- "Generate terrain tables & hex links" button — runs `ensureTerrainTables`, `ensureAllRollerLinks`, `backfillTerrainLinks`.
- Hex orientation, grid dimensions, hex gap, custom icons folder, road/river colors, terrain palette editor (drag-to-reorder, click entry to open `TerrainEntryEditorModal`).
### Data model
- **Hex notes**: `{hexFolder}/{region}/{x}_{y}.md`. Created via `createHexNote(x, y, region)` using the configured template or `defaultHexTemplate.md`.
- **Template placeholders**: `{{x}}`, `{{y}}`, `{{title}}`. Template must include `### Towns`, `### Dungeons`, `### Features`, `### Quests`, `### Factions`, `### Encounters Table` headings.
- **Terrain**: YAML frontmatter `terrain: <name>`. Read via `getTerrainFromFile` (metadata cache); written via `setTerrainInFile` (raw text patch). Both in `frontmatter.ts`.
- **Icon override**: frontmatter `icon: <filename>`. Read via `getIconOverrideFromFile`; written via `setIconOverrideInFile`.
- **Terrain icons**: plugin `icons/` folder + user custom icons folder → `plugin.availableIcons`. URLs via `getIconUrl(plugin, filename)`. Vault-sourced filenames tracked in `plugin.vaultIconsSet`.
- **Roads & rivers**: `settings.roadChains` / `settings.riverChains` — arrays of `string[]` chains, each element `"x_y"`. Drawn as SVG polylines over the grid.
- **Link sections** (`LINK_SECTIONS`): `"Towns" | "Dungeons" | "Features" | "Quests" | "Factions" | "Encounters Table"`. Wiki-links inserted under the matching `###` heading via `addLinkToSection`. Read back via `getLinksInSection`.
- **Text sections** (`TEXT_SECTIONS`): Description, Landmark, Hidden, Secret. Free text stored under `###` headings. Weather and Hooks & Rumors are also text sections but not in the `TEXT_SECTIONS` constant.
- **Random tables**: Markdown files with YAML `dice: N` frontmatter and a `| Result | Weight |` table. Parsed by `randomTable.ts`. Stored under `tablesFolder` (default `world/tables`). Terrain tables live at `{tablesFolder}/terrain/{name} - {description|encounters}.md`.
- **Workflows**: Markdown files with YAML frontmatter (`results-folder`, `template-file`) and a `| Table | Rolls | Label |` steps table. Stored under `workflowsFolder`. Templates stored at `{workflowsFolder}/templates/{name}.md`. Parsed/serialized by `workflow.ts`.
- **Settings** (`data.json`): `worldFolder`, `hexFolder`, `townsFolder`, `dungeonsFolder`, `questsFolder`, `featuresFolder`, `factionsFolder`, `tablesFolder`, `workflowsFolder`, `iconsFolder`, `templatePath`, `hexGap`, `hexOrientation`, `terrainPalette`, `gridSize`, `gridOffset`, `zoomLevel`, `roadChains`, `riverChains`, `roadColor`, `riverColor`, `defaultTableDice`, `regions`.
### Hex orientations
- **Pointy-top**: points north/south, flat sides east/west. Odd rows offset right. Adjacency via odd-r offset.
- **Flat-top** (default): flat sides north/south, points east/west. Odd columns offset down. Adjacency via odd-q offset.
- `hexNeighbors(x, y)` in `HexMapView` branches on `settings.hexOrientation`.
### Key conventions
- Folder paths normalised (no leading/trailing slashes) via `normalizeFolder()`.
- Links use vault-relative paths via `metadataCache.fileToLinktext(file, sourcePath)`.
- `obsidian` is an esbuild external — never bundled; provided by Obsidian at runtime.
- CSS classes use the `duckmage-` prefix (styles in `styles.css`).
- View/modal/tab files use `import type DuckmagePlugin` to avoid circular runtime dependencies.
- Paint tool methods (`onHexPaintClick`, `onHexIconClick`) are synchronous — DOM patched immediately, file writes queued via coalescing queue.
- Link-tool click handlers (`onHexTableLinkClick`, `onHexFactionLinkClick`) are async — no coalescing needed (one-shot writes).
- `onChanged` callback from `HexEditorModal` defers `renderGrid()` 300 ms when no terrain/icon overrides are passed (link-only changes) to avoid a metadata-cache race after `vault.modify`.
- Modals that preload file content accept an optional `preloaded` / `initialContent` parameter — callers that already hold the file content pass it in to avoid a redundant vault read.
- Exclude notes whose `basename` starts with `"_"` whenever enumerating vault files for display, dropdowns, or linking. Pattern: `.filter(f => !f.basename.startsWith("_"))`.
- **All modals must extend `DuckmageModal`** (`src/DuckmageModal.ts`) rather than Obsidian's `Modal` directly. `DuckmageModal` is the single place for shared modal behaviour. Any functionality needed by more than one modal belongs there, not in `utils.ts` or duplicated across files.
- All modals call `this.makeDraggable()` in `onOpen` (inherited from `DuckmageModal`), which adds the `duckmage-editor-modal-drag` class and locks dragging to the title-bar area. (`FileLinkSuggestModal` is the sole exception — it extends `SuggestModal` and cannot inherit from `DuckmageModal`.)
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development (watch mode with inline sourcemaps) — use with Hot Reload (see below)
npm run dev
# Production build (type-checks then bundles)
npm run build
# Run tests (vitest)
npm test
# Bump version (updates manifest.json and versions.json, stages both)
npm run version
```
Slash commands (invoke inside Claude Code):
- `/dev` — starts esbuild in watch mode (long-running; pairs with Hot Reload)
- `/rebuild` — one-off production build + test run; reports errors if either fails
**Always run `/rebuild` after making code changes.** The TypeScript type-check and test suite are the only automated verification — catch errors before the user does.
## Dev loop
Each coding session:
1. Run `npm run dev` in a terminal (or `/dev` from Claude Code) — esbuild watches for changes and rebuilds `main.js` on every save
2. After a rebuild, reload the plugin in Obsidian:
```bash
powershell.exe -Command "obsidian plugin:reload id=duckmage-plugin"
```
3. Use `/rebuild` for a final production build before committing (runs the TypeScript type-check and tests too)
## Architecture
The plugin source is split across `main.ts` (entry point re-export) and modules under `src/`. esbuild bundles everything into `main.js`, which Obsidian loads directly.
See `ARCHITECTURE.md` for a full overview of the system.
### Source layout
```
main.ts ← thin re-export: export { default } from "./src/DuckmagePlugin"
src/
DuckmagePlugin.ts ← Plugin class (entry point, default export)
HexMapView.ts ← ItemView — hex grid, all drawing tools, inline modals
HexEditorModal.ts ← Modal — right-click per-hex editor (terrain, links, notes, icon override)
HexTableView.ts ← ItemView — spreadsheet view of all hex notes with filters/sort
TerrainPickerModal.ts ← Modal — full terrain palette picker for the terrain paint tool
TerrainEntryEditorModal.ts ← Modal — edit a single terrain palette entry (name, color, icon)
IconPickerModal.ts ← Modal — icon picker for the icon paint tool
RegionModal.ts ← Modal — switch active region, create/rename/delete regions
FileLinkSuggestModal.ts ← SuggestModal — file picker scoped to worldFolder
RandomTableView.ts ← ItemView — random table + workflow browser (tabbed: Tables / Workflows)
RandomTableModal.ts ← Modal — inline roll modal used from HexEditorModal
RandomTableEditorModal.ts ← Modal — edit entries of a random table file
WorkflowEditorModal.ts ← Modal — edit workflow definition (steps, template, results folder)
WorkflowWizardModal.ts ← Modal — execute a workflow (roll steps, fill template, save as note)
randomTable.ts ← Pure logic: parse, roll, weight, die-range helpers
workflow.ts ← Pure logic: parse/serialize workflows, generate templates, placeholder helpers
DuckmageSettingTab.ts ← PluginSettingTab — settings UI
types.ts ← Interfaces & type constants (TerrainColor, DuckmagePluginSettings, LINK_SECTIONS, TEXT_SECTIONS)
constants.ts ← Runtime constants (VIEW_TYPE_*, DEFAULT_TERRAIN_PALETTE, DEFAULT_SETTINGS)
defaultHexTemplate.md ← Built-in hex note template (imported as text via esbuild loader)
frontmatter.ts ← YAML frontmatter helpers (terrain + icon override read/write)
sections.ts ← Markdown section helpers (addLinkToSection, getLinksInSection, getAllSectionData, …)
utils.ts ← Shared utilities (normalizeFolder, getIconUrl, makeTableTemplate)
md.d.ts ← TypeScript declaration for "*.md" text imports
```
The `.md` loader is configured in `esbuild.config.mjs` (`loader: { '.md': 'text' }`), allowing `defaultHexTemplate.md` to be imported as a plain string.
### Plugin purpose
Renders an interactive hex-grid map for tabletop RPG world-building inside Obsidian. Each hex cell corresponds to a Markdown note on disk. The map supports terrain painting, icon painting, road/river chain drawing, link-to-hex tools (random tables, factions), panning, and zooming. A spreadsheet view summarises all hex notes with filtering. A random tables view lets users browse, roll, and edit weighted random tables, and execute multi-step workflows that chain table rolls into a filled template note.
### Key classes
- **`DuckmagePlugin`** (`src/DuckmagePlugin.ts`) — Main plugin entry point. Registers views (`HexMapView`, `HexTableView`, `RandomTableView`), ribbon icons, commands, and the settings tab. Key public API:
- `hexPath(x, y)` — vault-relative path for a hex note
- `createHexNote(x, y)` — creates hex note from template
- `loadAvailableIcons()` — merges plugin `icons/` with user custom icons folder into `availableIcons: string[]`
- `refreshHexMap()` — re-renders all open `HexMapView` instances
- `ensureTerrainTables()` — creates missing terrain table files under `tablesFolder/terrain/`
- `ensureAllRollerLinks()` — adds roller-link preamble to all table files
- `backfillTerrainLinks()` — links each hex note's terrain table into its Encounters Table section
- `buildRollerLink(path)` — generates the `[Roll](<obsidian://…>)` URI for a table file
- **`HexMapView`** (`src/HexMapView.ts`, extends `ItemView`) — Renders the hex grid and handles all user interaction.
- **DOM structure**: `contentEl` (`.duckmage-hex-map-container`) → `.duckmage-hex-map-clip` (panning viewport, `overflow: hidden`) + `.duckmage-hex-map-controls` (overlay for buttons, `pointer-events: none`). This keeps toolbar/expand buttons unclipped by the viewport transform.
- **`renderGrid(terrainOverrides?, iconOverrides?)`** — full DOM re-render. Optional override Maps allow passing values not yet in the metadata cache.
- **Drawing tools**: `drawingMode` union `"road" | "river" | "terrain" | "icon" | "tableLink" | "factionLink" | null`. Toolbar buttons toggle mode; each mode has a `handle*Button()` opener, `exit*Mode()` closer, and `onHex*Click()` handler.
- **Write queues**: `scheduleTerrainWrite` / `scheduleIconWrite` coalesce rapid repaints — only the latest value per hex is written, preventing stale overwrites.
- **Link tools** (`tableLink`, `factionLink`): pick a file via folder-tree modal, then click hexes to add a wiki-link to the `Encounters Table` or `Factions` section. Visual feedback: badge span + CSS ripple blip animation (`duckmage-hex-blip`).
- Left-click: opens/creates hex note (normal), paints terrain/icon, extends road/river chain, or adds a link.
- Right-click: opens `HexEditorModal` (normal), deletes road/river node, or exits current tool mode.
- Expand buttons (+) grow the grid in four cardinal directions, adjusting `gridOffset` and `gridSize` in settings.
- **`centerOnHex(x, y)`** (public) — pans the viewport to centre on a given hex coordinate.
- **`activeRegionName`** (public) — the currently displayed region name.
- **`HexEditorModal`** (`src/HexEditorModal.ts`, extends `Modal`) — The right-click per-hex editor:
- Terrain picker (2-row scrollable grid) with icon override and "Clear terrain".
- Dropdown link sections for **Towns, Dungeons, Features, Quests, Factions, Encounters Table** — each backed by its own configured folder, rendered via `renderDropdownSection`. Uses `LinkPickerModal` internally (file list + create-new).
- Free-text note sections: Description, Landmark, Hidden, Secret, Weather, Hooks & Rumors.
- Fetches all section data in a single read before touching the DOM (`getAllSectionData`).
- **`HexTableView`** (`src/HexTableView.ts`, extends `ItemView`) — Spreadsheet of all hex notes:
- Columns: Hex (coords + jump button), Terrain, Description, Landmark, Towns, Dungeons, Features, Quests, Factions, Enc. Table, Hidden, Secret, Weather, Hooks & Rumors.
- Enc. Table cell shows the basename of the linked table file; clicking opens `RandomTableView` at that file.
- Toolbar filters: X/Y range inputs, terrain multi-select (left-click include / right-click exclude), Has Town/Dungeon/Feature/Quest/Faction checkboxes, region selector, X→Y / Y→X sort priority, Asc/Desc direction.
- Live updates on vault `modify` events (300 ms debounce per file).
- Jump button (◎) calls `HexMapView.centerOnHex(x, y)` on the open map view.
- **`RandomTableView`** (`src/RandomTableView.ts`, extends `ItemView`) — Tabbed browser with two modes:
- **Tables mode**: folder tree + search + new-table creator. Detail pane shows entries with die-range and % odds, die-size selector, Roll button, copy result, roll history. Edit opens `RandomTableEditorModal`. Bottom of detail pane shows "Workflows using this table" links (each opens that workflow in Workflows mode) and a "+ New workflow with this table" link.
- **Workflows mode**: folder tree of workflow files. Detail pane shows each step as a clickable link (opens that table in Tables mode) with roll count. Edit opens `WorkflowEditorModal`. "Roll workflow" button opens `WorkflowWizardModal`.
- **`openTable(filePath)`** (public) — switches to Tables mode, expands ancestor folders so the target is visible, and loads the table detail; called from `HexTableView` (Enc. Table cell), `HexEditorModal`, workflow step links, and the protocol handler.
- **`RandomTableModal`** (`src/RandomTableModal.ts`, extends `Modal`) — Lightweight inline roll modal opened from `HexEditorModal` (🎲 button per link section or 📖 for terrain description). Skips the picker if `initialFilePath` is supplied.
- **`RandomTableEditorModal`** (`src/RandomTableEditorModal.ts`, extends `Modal`) — Edit table entries (result text + weight), linked folder, description, filter flags. Auto-saves on close. Accepts optional `initialContent` to avoid a redundant vault read when the caller already has the file content.
- **`WorkflowEditorModal`** (`src/WorkflowEditorModal.ts`, extends `Modal`) — Edit a workflow definition:
- Rename workflow, set results folder, manage steps (table picker + roll count + label) with drag-to-reorder.
- Template textarea with live placeholder validation — shows missing `$placeholder` names.
- Template auto-syncs: new step appends `## label\n$label` block; label change updates the heading AND all `$var`/`$var_N` placeholder references; roll-count change adds or removes `_N` suffixed placeholders (reducing to 1 collapses back to bare `$var`).
- Auto-saves workflow file and template file on close. Modal is draggable by its title bar.
- **`WorkflowWizardModal`** (`src/WorkflowWizardModal.ts`, extends `Modal`) — Execute a workflow:
- Shows each step with a manual-pick dropdown and a Roll button. Fixed-width controls to prevent layout shift on roll.
- Result textarea shows the template filled with rolled values.
- Save section writes the filled result as a new vault note in the configured results folder.
- **`RegionModal`** (`src/RegionModal.ts`, extends `Modal`) — Manage hex map regions:
- Switch the active region displayed on the hex map.
- Create, rename, and delete regions. Regions map to subfolders under `hexFolder`.
- **`TerrainEntryEditorModal`** (`src/TerrainEntryEditorModal.ts`, extends `Modal`) — Edit a single terrain palette entry: name, color, icon (with preview), and icon color tint. Changes are staged in pending fields and only written to the palette on Save.
- **`TerrainPickerModal`** (`src/TerrainPickerModal.ts`) — Full terrain palette picker (no row cap). Includes "Clear" to erase terrain.
- **`IconPickerModal`** (`src/IconPickerModal.ts`) — Full icon picker with image previews. Includes "Remove" option.
- **`FileLinkSuggestModal`** (`src/FileLinkSuggestModal.ts`) — Reusable fuzzy-search file picker scoped to a configured folder.
- **`DuckmageSettingTab`** (`src/DuckmageSettingTab.ts`) — Settings UI:
- "Generate folders" button — fills blank folder settings with defaults under `worldFolder` and creates the folders.
- Folder paths: world, hexes, towns, dungeons, quests, features, factions, tables, workflows.
- "Generate terrain tables & hex links" button — runs `ensureTerrainTables`, `ensureAllRollerLinks`, `backfillTerrainLinks`.
- Hex orientation, grid dimensions, hex gap, custom icons folder, road/river colors, terrain palette editor (drag-to-reorder, click entry to open `TerrainEntryEditorModal`).
### Data model
- **Hex notes**: `{hexFolder}/{region}/{x}_{y}.md`. Created via `createHexNote(x, y, region)` using the configured template or `defaultHexTemplate.md`.
- **Template placeholders**: `{{x}}`, `{{y}}`, `{{title}}`. Template must include `### Towns`, `### Dungeons`, `### Features`, `### Quests`, `### Factions`, `### Encounters Table` headings.
- **Terrain**: YAML frontmatter `terrain: <name>`. Read via `getTerrainFromFile` (metadata cache); written via `setTerrainInFile` (raw text patch). Both in `frontmatter.ts`.
- **Icon override**: frontmatter `icon: <filename>`. Read via `getIconOverrideFromFile`; written via `setIconOverrideInFile`.
- **Terrain icons**: plugin `icons/` folder + user custom icons folder → `plugin.availableIcons`. URLs via `getIconUrl(plugin, filename)`. Vault-sourced filenames tracked in `plugin.vaultIconsSet`.
- **Roads & rivers**: `settings.roadChains` / `settings.riverChains` — arrays of `string[]` chains, each element `"x_y"`. Drawn as SVG polylines over the grid.
- **Link sections** (`LINK_SECTIONS`): `"Towns" | "Dungeons" | "Features" | "Quests" | "Factions" | "Encounters Table"`. Wiki-links inserted under the matching `###` heading via `addLinkToSection`. Read back via `getLinksInSection`.
- **Text sections** (`TEXT_SECTIONS`): Description, Landmark, Hidden, Secret. Free text stored under `###` headings. Weather and Hooks & Rumors are also text sections but not in the `TEXT_SECTIONS` constant.
- **Random tables**: Markdown files with YAML `dice: N` frontmatter and a `| Result | Weight |` table. Parsed by `randomTable.ts`. Stored under `tablesFolder` (default `world/tables`). Terrain tables live at `{tablesFolder}/terrain/{name} - {description|encounters}.md`.
- **Workflows**: Markdown files with YAML frontmatter (`results-folder`, `template-file`) and a `| Table | Rolls | Label |` steps table. Stored under `workflowsFolder`. Templates stored at `{workflowsFolder}/templates/{name}.md`. Parsed/serialized by `workflow.ts`.
- **Settings** (`data.json`): `worldFolder`, `hexFolder`, `townsFolder`, `dungeonsFolder`, `questsFolder`, `featuresFolder`, `factionsFolder`, `tablesFolder`, `workflowsFolder`, `iconsFolder`, `templatePath`, `hexGap`, `hexOrientation`, `terrainPalette`, `gridSize`, `gridOffset`, `zoomLevel`, `roadChains`, `riverChains`, `roadColor`, `riverColor`, `defaultTableDice`, `regions`.
### Hex orientations
- **Pointy-top**: points north/south, flat sides east/west. Odd rows offset right. Adjacency via odd-r offset.
- **Flat-top** (default): flat sides north/south, points east/west. Odd columns offset down. Adjacency via odd-q offset.
- `hexNeighbors(x, y)` in `HexMapView` branches on `settings.hexOrientation`.
### Key conventions
- Folder paths normalised (no leading/trailing slashes) via `normalizeFolder()`.
- Links use vault-relative paths via `metadataCache.fileToLinktext(file, sourcePath)`.
- `obsidian` is an esbuild external — never bundled; provided by Obsidian at runtime.
- CSS classes use the `duckmage-` prefix (styles in `styles.css`).
- View/modal/tab files use `import type DuckmagePlugin` to avoid circular runtime dependencies.
- Paint tool methods (`onHexPaintClick`, `onHexIconClick`) are synchronous — DOM patched immediately, file writes queued via coalescing queue.
- Link-tool click handlers (`onHexTableLinkClick`, `onHexFactionLinkClick`) are async — no coalescing needed (one-shot writes).
- `onChanged` callback from `HexEditorModal` defers `renderGrid()` 300 ms when no terrain/icon overrides are passed (link-only changes) to avoid a metadata-cache race after `vault.modify`.
- Modals that preload file content accept an optional `preloaded` / `initialContent` parameter — callers that already hold the file content pass it in to avoid a redundant vault read.
- Exclude notes whose `basename` starts with `"_"` whenever enumerating vault files for display, dropdowns, or linking. Pattern: `.filter(f => !f.basename.startsWith("_"))`.
- **All modals must extend `DuckmageModal`** (`src/DuckmageModal.ts`) rather than Obsidian's `Modal` directly. `DuckmageModal` is the single place for shared modal behaviour. Any functionality needed by more than one modal belongs there, not in `utils.ts` or duplicated across files.
- All modals call `this.makeDraggable()` in `onOpen` (inherited from `DuckmageModal`), which adds the `duckmage-editor-modal-drag` class and locks dragging to the title-bar area. (`FileLinkSuggestModal` is the sole exception — it extends `SuggestModal` and cannot inherit from `DuckmageModal`.)

42
LICENSE
View file

@ -1,21 +1,21 @@
MIT License
Copyright (c) 2025 Mark B
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
MIT License
Copyright (c) 2025 Mark B
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

558
README.md
View file

@ -1,279 +1,279 @@
# Hexmaker
An Obsidian plugin for tabletop RPG hex-map world-building. Each hex on the map is a Markdown note in your vault — attach terrain, locations, paths, and prose directly to the geography of your world.
![A populated hex map showing multiple terrain types, icons, and drawn paths](docs/Hero.PNG)
---
## What it does
Hexmaker gives you an interactive hex grid that lives inside Obsidian. Paint terrain, draw roads and rivers, link town and dungeon notes to individual hexes, roll random encounters, and browse everything in a spreadsheet view — all without leaving your vault. Every hex is a plain Markdown file you own.
### Hex editor
Right-click any hex to open the editor: set terrain, override the icon, link Towns, Dungeons, Features, and Encounters Tables, and write freeform notes (Description, Landmark, Hidden, Secret, Weather, Hooks & Rumors). A 🎲 button on each section lets you roll any random table and append the result inline.
![The hex editor showing terrain, links, and notes for a single hex](docs/Editor.PNG)
### Terrain painting
Switch to the Terrain tool, pick a colour from your palette, and drag across hexes to paint. Choose a 1×, 3×, or 7× brush for broad strokes. An eyedropper lets you sample an existing hex's terrain as your active brush.
![Terrain being painted across multiple hexes with the drag brush](docs/TerrainPaint.gif)
### Path drawing
Click the Path button to open the path picker. Select any defined path type — road, river, or anything you've created — then click hexes to lay down a chain. Each type has its own colour, width, line style (solid / dashed / dotted), and routing mode (through hex centres, meandering between them, or tracing hex edges). Edit types at any time from the map toolbar.
![A road path being drawn hex by hex across the map](docs/PathDraw.gif)
### Random tables
A two-panel view for rolling and managing weighted random tables. Click any table to open it, hit Roll to highlight a result, and see percentage odds for every entry. Create new table files from the toolbar, edit entries inline, and chain tables together into multi-step **workflows** that fill a template note with rolled results.
![Rolling on a random table and seeing the result highlighted](docs/RandomTable.gif)
### Hex table view
A scrollable spreadsheet of every hex note — one row per hex, columns for terrain, description, towns, dungeons, features, quests, factions, encounters table, and all the freeform sections. Click any cell to edit it in place. Filter by region, terrain type, or the presence of specific link types. The ◎ button jumps the map view to that hex's position.
![The hex table view showing a grid of hex notes with populated columns](docs/Table.PNG)
---
## Manual Installation
1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/sbuffkin/obsidian-hexmaker/releases/latest).
2. In your vault, create the folder `.obsidian/plugins/hexmaker-plugin/`.
3. Copy the three downloaded files into that folder.
4. In Obsidian, open **Settings → Community plugins**, find **Hexmaker** in the list, and enable it.
---
## Getting started
After enabling the plugin, do this once before you start mapping:
### 1. Set your world folder
Open **Settings → Hexmaker** and enter a root folder name in **World folder** (e.g. `RPG/world`). This is the base for all other folders.
### 2. Generate folders
Click **Generate folders**. This fills in the hex, towns, dungeons, tables, and other folder settings with sensible defaults under your world folder and creates them in your vault. Any field you've already filled in is left untouched.
### 3. Open the Hex Map
Click the map icon in the left ribbon (or use the command palette: **Open Hexmaker hex map**). You'll be prompted to create your first region — give it a name, choose its size, and pick a terrain palette.
### 4. Generate terrain tables
Once your folders are set, go back to **Settings → Hexmaker** and click **Generate terrain tables & hex links**. This creates a description and encounters table file for every terrain type and links them into any existing hex notes. It's safe to run again at any time.
You're ready to start mapping.
---
## Features
### Hex Map view
An interactive hex grid rendered as an Obsidian panel.
- **Left-click** a hex to open (or create) its note.
- **Right-click** a hex to open the hex editor.
- **Pan** by clicking and dragging with the left or middle mouse button.
- **Zoom** with the scroll wheel.
- **Expand** the grid with the `+`/`` buttons in the toolbar.
- **Go to hex** (`⌖` button) — enter coordinates to centre the view on a specific hex.
### Regions
The map is divided into named regions, each stored as a subfolder under your hex folder. Switch, create, rename, and delete regions from the region button in the toolbar.
Each region is assigned a **terrain palette** at creation time (see below).
### Hex editor (right-click menu)
A side-panel modal for editing a hex note without leaving the map.
- **Terrain picker** — select the terrain type for the hex from the region's palette.
- **Icon override** — override the default terrain icon with any icon in your icons folder.
- **Towns / Dungeons** — link existing notes from their configured folders, or create a new note by name. Linked items are clickable and open in a new tab. Each entry has a remove button.
- **Features** — free-form wiki-link list for anything else.
- **Encounters Table** — link random table files to a hex. Clicking a linked table opens the Random Tables view with that table pre-selected.
- **Notes sections** — Description, Landmark, Hidden, Secret, Encounters, Weather, Hooks & Rumors — inline text areas with a 🎲 roll button to append a result from any random table.
- **Open note** link next to the hex coordinates opens the full note in a new tab.
### Random Tables view
Open via **Command palette → "Open Hexmaker random tables"** or the 🎲 toolbar button on the hex map.
A two-panel view for managing and rolling on random tables.
- **Left panel** — lists all `.md` files in the configured Tables folder. Click a table to load it. **+ New** creates a file from the default template.
- **Right panel** — shows the table's entries with odds (percentage or die range). **Roll** button highlights the winning row and shows the result. The result is editable before use. Roll history shows the last 5 results.
- **Change die** dropdown — updates the `dice:` frontmatter and recalculates die ranges.
### Workflows view
Open via the Workflows tab in the Random Tables panel.
Chain multiple table rolls together into a filled template note.
- **Create a workflow** — define steps (each step rolls a table N times), a template with `$placeholder` variables, and a results folder.
- **Roll a workflow** — each step shows a dropdown and a Roll button. The template fills in live as you roll. Save the result as a new vault note.
### Terrain tables & hex linking
Each terrain type has two auto-generated table files: `{terrain} - description.md` and `{terrain} - encounters.md`, stored under `{tablesFolder}/terrain/`.
> ⚠️ **Configure all folder settings before clicking Generate.** The Generate button (Settings → Hexmaker → Generate world data) creates the terrain table files and links each hex note's terrain encounters table into its Encounters Table section. It is safe to run multiple times.
### Drawing tools (toolbar)
Toggle tools from the toolbar above the map. **Right-click** off a hex to exit any tool.
| Tool | Left-click | Right-click (on hex) |
|------|-----------|----------------------|
| **Terrain** | Paint terrain on a hex (drag to paint multiple) | — |
| **Icon** | Paint an icon override on a hex | — |
| **Path** | Add hex to the active path chain | Remove hex from chain |
**Terrain painter extras:**
- Clicking the Terrain button always reopens the palette so you can switch colours mid-session.
- **Pick** (eyedropper) — samples the terrain from the next hex you click and sets it as the active brush.
- **Clear** — paints the "no terrain" state.
- **Brush size** — paint 1×, 3×, or 7× hex radius at once.
**Path tool:** Click the Path button to open the path type picker. Select a type to start drawing, or switch to edit mode to create, rename, recolour, or delete path types. Each path type has a name, colour, line width, line style (solid / dashed / dotted), and routing mode:
- **Through** — smooth Bezier curve through hex centres.
- **Meander** — gentle curve through the midpoints between hex centres (good for rivers).
- **Edge** — traces strictly along the hex polygon boundary lines between hexes.
### Hex table view
Open via **Command palette → "Open Hexmaker hex table"**.
A scrollable reference table of every hex note, with one row per hex and columns for all sections.
- **Coordinates column** — click to open the hex note.
- **Terrain column** — click to open the terrain picker.
- **Town / Dungeon columns** — click an empty cell to add a link (pick existing or create new); click a populated cell to open the note (or a navigation list for multiple links).
- **Text section columns** (Description, Landmark, etc.) — click any cell (including empty ones) to open an inline editor. Saves directly to the section in the hex note, creating the note and section if they don't exist yet.
- **Resize columns** by dragging the border between column headers.
- **Refresh** button reloads all data from disk.
---
## Settings
Open **Settings → Hexmaker** to configure:
| Setting | Description |
|---------|-------------|
| **World folder** | Root folder for world notes (used by the Features file picker). |
| **Hex folder** | Folder where hex notes are stored, e.g. `RPG/world/hexes`. |
| **Towns folder** | Scopes the Towns dropdown to a specific folder. |
| **Dungeons folder** | Scopes the Dungeons dropdown to a specific folder. |
| **Tables folder** | Folder for random table files. Terrain tables are created in a `terrain/` subfolder here. |
| **Default die** | Die size used when creating new table files (d4d100). |
| **Icons folder** | Folder containing `.png` icon files available as custom terrain/hex icons. |
| **Template path** | Path to a custom hex note template. Supports `{{x}}`, `{{y}}`, `{{title}}` placeholders. Leave blank to use the built-in template. |
| **Hex gap** | Gap between hexes in pixels. |
| **Grid size** | Number of columns and rows in the map grid. |
| **Hex orientation** | `pointy` (default) or `flat` top hex style. |
| **Path types** | Named path types used by the Path drawing tool. Each type has a name, colour, width, line style, and routing mode. Manage them from the Path button on the hex map toolbar. |
| **Terrain palettes** | Named palettes of terrain types. Each palette has a name and a list of terrain entries (name, colour, optional icon). Palettes are assigned to regions at creation time and cannot be changed after. Edit palette contents from the terrain tool on the hex map. |
| **Generate** | ⚠️ Configure all folders first. Creates missing terrain table files and links each hex's terrain encounters table into the hex note. |
---
## Hex notes
Each hex note lives at `{hexFolder}/{region}/{x}_{y}.md` (e.g. `RPG/world/hexes/Overworld/3_7.md`).
**Frontmatter:**
```yaml
---
terrain: Forest
---
```
**Sections** (used by the editor and table view):
| Heading | Type | Purpose |
|---------|------|---------|
| `### description` | Text | What the party sees and feels |
| `### landmark` | Text | The standout visible feature |
| `### Towns` | Links | Settlement links |
| `### Dungeons` | Links | Dungeon/site links |
| `### Features` | Links | Other points of interest |
| `### Encounters Table` | Links | Random table links (linked to terrain by Generate) |
| `### hidden` | Text | Discoverable with effort |
| `### secret` | Text | Revealed only through investigation |
| `### encounters` | Text | Encounter table notes |
| `### weather` | Text | Weather notes |
| `### hooks & rumors` | Text | Adventure seeds |
You can use your own template (configured in Settings). Any `### Heading` that matches a section name will be picked up automatically.
---
## Development
```bash
npm install # install dependencies
npm run dev # watch mode — rebuilds main.js on every save
npm run build # production build (type-check + bundle)
npm run version # bump version (updates manifest.json and versions.json)
npm test # run the test suite
```
The built output is `main.js` in the repo root. Obsidian loads this file directly from the plugin folder.
**Reload the plugin after a build:**
```js
// Paste in Obsidian developer console (Ctrl+Shift+I)
app.plugins.disablePlugin('hexmaker-plugin');
app.plugins.enablePlugin('hexmaker-plugin');
```
### Source layout
```
main.ts ← re-exports HexmakerPlugin
src/
HexmakerPlugin.ts ← plugin entry point
HexmakerSettingTab.ts ← settings UI
HexmakerModal.ts ← base modal class (all modals extend this)
types.ts ← interfaces and type constants
constants.ts ← runtime constants and defaults
frontmatter.ts ← terrain/icon YAML read/write
sections.ts ← markdown section read/write helpers
utils.ts ← shared utilities
defaultHexTemplate.md ← built-in hex note template
hex-map/
HexMapView.ts ← interactive hex grid (ItemView)
HexEditorModal.ts ← right-click hex editor (Modal)
TerrainPickerModal.ts ← terrain palette picker
TerrainEntryEditorModal.ts ← edit a single terrain entry
IconPickerModal.ts ← icon override picker
RegionModal.ts ← region management
PathPickerModal.ts ← path type picker
PathTypeEditorModal.ts ← edit a single path type
FileLinkSuggestModal.ts ← file picker scoped to a folder
hex-table/
HexTableView.ts ← hex reference table (ItemView)
HexCellModal.ts ← inline cell editor
HexTerrainPickerModal.ts ← terrain picker in table view
random-tables/
RandomTableView.ts ← random tables + workflows panel (ItemView)
RandomTableModal.ts ← inline roll modal
RandomTableEditorModal.ts ← edit table entries
WorkflowEditorModal.ts ← edit workflow definition
WorkflowWizardModal.ts ← execute a workflow
randomTable.ts ← parse/roll/odds utilities
workflow.ts ← workflow parse/serialize utilities
```
### Troubleshooting
- **"Failed to load plugin"** — `main.js` is missing. Run `npm run build`.
- **Viewing logs** — Press `Ctrl+Shift+I` (Windows/Linux) or `Cmd+Option+I` (Mac) to open the developer console.
---
## Third-party libraries
This plugin bundles [MiniSearch](https://github.com/lucaong/minisearch) (© Luca Ongaro, MIT License) for in-memory full-text search.
# Hexmaker
An Obsidian plugin for tabletop RPG hex-map world-building. Each hex on the map is a Markdown note in your vault — attach terrain, locations, paths, and prose directly to the geography of your world.
![A populated hex map showing multiple terrain types, icons, and drawn paths](docs/Hero.PNG)
---
## What it does
Hexmaker gives you an interactive hex grid that lives inside Obsidian. Paint terrain, draw roads and rivers, link town and dungeon notes to individual hexes, roll random encounters, and browse everything in a spreadsheet view — all without leaving your vault. Every hex is a plain Markdown file you own.
### Hex editor
Right-click any hex to open the editor: set terrain, override the icon, link Towns, Dungeons, Features, and Encounters Tables, and write freeform notes (Description, Landmark, Hidden, Secret, Weather, Hooks & Rumors). A 🎲 button on each section lets you roll any random table and append the result inline.
![The hex editor showing terrain, links, and notes for a single hex](docs/Editor.PNG)
### Terrain painting
Switch to the Terrain tool, pick a colour from your palette, and drag across hexes to paint. Choose a 1×, 3×, or 7× brush for broad strokes. An eyedropper lets you sample an existing hex's terrain as your active brush.
![Terrain being painted across multiple hexes with the drag brush](docs/TerrainPaint.gif)
### Path drawing
Click the Path button to open the path picker. Select any defined path type — road, river, or anything you've created — then click hexes to lay down a chain. Each type has its own colour, width, line style (solid / dashed / dotted), and routing mode (through hex centres, meandering between them, or tracing hex edges). Edit types at any time from the map toolbar.
![A road path being drawn hex by hex across the map](docs/PathDraw.gif)
### Random tables
A two-panel view for rolling and managing weighted random tables. Click any table to open it, hit Roll to highlight a result, and see percentage odds for every entry. Create new table files from the toolbar, edit entries inline, and chain tables together into multi-step **workflows** that fill a template note with rolled results.
![Rolling on a random table and seeing the result highlighted](docs/RandomTable.gif)
### Hex table view
A scrollable spreadsheet of every hex note — one row per hex, columns for terrain, description, towns, dungeons, features, quests, factions, encounters table, and all the freeform sections. Click any cell to edit it in place. Filter by region, terrain type, or the presence of specific link types. The ◎ button jumps the map view to that hex's position.
![The hex table view showing a grid of hex notes with populated columns](docs/Table.PNG)
---
## Manual Installation
1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/sbuffkin/obsidian-hexmaker/releases/latest).
2. In your vault, create the folder `.obsidian/plugins/hexmaker-plugin/`.
3. Copy the three downloaded files into that folder.
4. In Obsidian, open **Settings → Community plugins**, find **Hexmaker** in the list, and enable it.
---
## Getting started
After enabling the plugin, do this once before you start mapping:
### 1. Set your world folder
Open **Settings → Hexmaker** and enter a root folder name in **World folder** (e.g. `RPG/world`). This is the base for all other folders.
### 2. Generate folders
Click **Generate folders**. This fills in the hex, towns, dungeons, tables, and other folder settings with sensible defaults under your world folder and creates them in your vault. Any field you've already filled in is left untouched.
### 3. Open the Hex Map
Click the map icon in the left ribbon (or use the command palette: **Open Hexmaker hex map**). You'll be prompted to create your first region — give it a name, choose its size, and pick a terrain palette.
### 4. Generate terrain tables
Once your folders are set, go back to **Settings → Hexmaker** and click **Generate terrain tables & hex links**. This creates a description and encounters table file for every terrain type and links them into any existing hex notes. It's safe to run again at any time.
You're ready to start mapping.
---
## Features
### Hex Map view
An interactive hex grid rendered as an Obsidian panel.
- **Left-click** a hex to open (or create) its note.
- **Right-click** a hex to open the hex editor.
- **Pan** by clicking and dragging with the left or middle mouse button.
- **Zoom** with the scroll wheel.
- **Expand** the grid with the `+`/`` buttons in the toolbar.
- **Go to hex** (`⌖` button) — enter coordinates to centre the view on a specific hex.
### Regions
The map is divided into named regions, each stored as a subfolder under your hex folder. Switch, create, rename, and delete regions from the region button in the toolbar.
Each region is assigned a **terrain palette** at creation time (see below).
### Hex editor (right-click menu)
A side-panel modal for editing a hex note without leaving the map.
- **Terrain picker** — select the terrain type for the hex from the region's palette.
- **Icon override** — override the default terrain icon with any icon in your icons folder.
- **Towns / Dungeons** — link existing notes from their configured folders, or create a new note by name. Linked items are clickable and open in a new tab. Each entry has a remove button.
- **Features** — free-form wiki-link list for anything else.
- **Encounters Table** — link random table files to a hex. Clicking a linked table opens the Random Tables view with that table pre-selected.
- **Notes sections** — Description, Landmark, Hidden, Secret, Encounters, Weather, Hooks & Rumors — inline text areas with a 🎲 roll button to append a result from any random table.
- **Open note** link next to the hex coordinates opens the full note in a new tab.
### Random Tables view
Open via **Command palette → "Open Hexmaker random tables"** or the 🎲 toolbar button on the hex map.
A two-panel view for managing and rolling on random tables.
- **Left panel** — lists all `.md` files in the configured Tables folder. Click a table to load it. **+ New** creates a file from the default template.
- **Right panel** — shows the table's entries with odds (percentage or die range). **Roll** button highlights the winning row and shows the result. The result is editable before use. Roll history shows the last 5 results.
- **Change die** dropdown — updates the `dice:` frontmatter and recalculates die ranges.
### Workflows view
Open via the Workflows tab in the Random Tables panel.
Chain multiple table rolls together into a filled template note.
- **Create a workflow** — define steps (each step rolls a table N times), a template with `$placeholder` variables, and a results folder.
- **Roll a workflow** — each step shows a dropdown and a Roll button. The template fills in live as you roll. Save the result as a new vault note.
### Terrain tables & hex linking
Each terrain type has two auto-generated table files: `{terrain} - description.md` and `{terrain} - encounters.md`, stored under `{tablesFolder}/terrain/`.
> ⚠️ **Configure all folder settings before clicking Generate.** The Generate button (Settings → Hexmaker → Generate world data) creates the terrain table files and links each hex note's terrain encounters table into its Encounters Table section. It is safe to run multiple times.
### Drawing tools (toolbar)
Toggle tools from the toolbar above the map. **Right-click** off a hex to exit any tool.
| Tool | Left-click | Right-click (on hex) |
|------|-----------|----------------------|
| **Terrain** | Paint terrain on a hex (drag to paint multiple) | — |
| **Icon** | Paint an icon override on a hex | — |
| **Path** | Add hex to the active path chain | Remove hex from chain |
**Terrain painter extras:**
- Clicking the Terrain button always reopens the palette so you can switch colours mid-session.
- **Pick** (eyedropper) — samples the terrain from the next hex you click and sets it as the active brush.
- **Clear** — paints the "no terrain" state.
- **Brush size** — paint 1×, 3×, or 7× hex radius at once.
**Path tool:** Click the Path button to open the path type picker. Select a type to start drawing, or switch to edit mode to create, rename, recolour, or delete path types. Each path type has a name, colour, line width, line style (solid / dashed / dotted), and routing mode:
- **Through** — smooth Bezier curve through hex centres.
- **Meander** — gentle curve through the midpoints between hex centres (good for rivers).
- **Edge** — traces strictly along the hex polygon boundary lines between hexes.
### Hex table view
Open via **Command palette → "Open Hexmaker hex table"**.
A scrollable reference table of every hex note, with one row per hex and columns for all sections.
- **Coordinates column** — click to open the hex note.
- **Terrain column** — click to open the terrain picker.
- **Town / Dungeon columns** — click an empty cell to add a link (pick existing or create new); click a populated cell to open the note (or a navigation list for multiple links).
- **Text section columns** (Description, Landmark, etc.) — click any cell (including empty ones) to open an inline editor. Saves directly to the section in the hex note, creating the note and section if they don't exist yet.
- **Resize columns** by dragging the border between column headers.
- **Refresh** button reloads all data from disk.
---
## Settings
Open **Settings → Hexmaker** to configure:
| Setting | Description |
|---------|-------------|
| **World folder** | Root folder for world notes (used by the Features file picker). |
| **Hex folder** | Folder where hex notes are stored, e.g. `RPG/world/hexes`. |
| **Towns folder** | Scopes the Towns dropdown to a specific folder. |
| **Dungeons folder** | Scopes the Dungeons dropdown to a specific folder. |
| **Tables folder** | Folder for random table files. Terrain tables are created in a `terrain/` subfolder here. |
| **Default die** | Die size used when creating new table files (d4d100). |
| **Icons folder** | Folder containing `.png` icon files available as custom terrain/hex icons. |
| **Template path** | Path to a custom hex note template. Supports `{{x}}`, `{{y}}`, `{{title}}` placeholders. Leave blank to use the built-in template. |
| **Hex gap** | Gap between hexes in pixels. |
| **Grid size** | Number of columns and rows in the map grid. |
| **Hex orientation** | `pointy` (default) or `flat` top hex style. |
| **Path types** | Named path types used by the Path drawing tool. Each type has a name, colour, width, line style, and routing mode. Manage them from the Path button on the hex map toolbar. |
| **Terrain palettes** | Named palettes of terrain types. Each palette has a name and a list of terrain entries (name, colour, optional icon). Palettes are assigned to regions at creation time and cannot be changed after. Edit palette contents from the terrain tool on the hex map. |
| **Generate** | ⚠️ Configure all folders first. Creates missing terrain table files and links each hex's terrain encounters table into the hex note. |
---
## Hex notes
Each hex note lives at `{hexFolder}/{region}/{x}_{y}.md` (e.g. `RPG/world/hexes/Overworld/3_7.md`).
**Frontmatter:**
```yaml
---
terrain: Forest
---
```
**Sections** (used by the editor and table view):
| Heading | Type | Purpose |
|---------|------|---------|
| `### description` | Text | What the party sees and feels |
| `### landmark` | Text | The standout visible feature |
| `### Towns` | Links | Settlement links |
| `### Dungeons` | Links | Dungeon/site links |
| `### Features` | Links | Other points of interest |
| `### Encounters Table` | Links | Random table links (linked to terrain by Generate) |
| `### hidden` | Text | Discoverable with effort |
| `### secret` | Text | Revealed only through investigation |
| `### encounters` | Text | Encounter table notes |
| `### weather` | Text | Weather notes |
| `### hooks & rumors` | Text | Adventure seeds |
You can use your own template (configured in Settings). Any `### Heading` that matches a section name will be picked up automatically.
---
## Development
```bash
npm install # install dependencies
npm run dev # watch mode — rebuilds main.js on every save
npm run build # production build (type-check + bundle)
npm run version # bump version (updates manifest.json and versions.json)
npm test # run the test suite
```
The built output is `main.js` in the repo root. Obsidian loads this file directly from the plugin folder.
**Reload the plugin after a build:**
```js
// Paste in Obsidian developer console (Ctrl+Shift+I)
app.plugins.disablePlugin('hexmaker-plugin');
app.plugins.enablePlugin('hexmaker-plugin');
```
### Source layout
```
main.ts ← re-exports HexmakerPlugin
src/
HexmakerPlugin.ts ← plugin entry point
HexmakerSettingTab.ts ← settings UI
HexmakerModal.ts ← base modal class (all modals extend this)
types.ts ← interfaces and type constants
constants.ts ← runtime constants and defaults
frontmatter.ts ← terrain/icon YAML read/write
sections.ts ← markdown section read/write helpers
utils.ts ← shared utilities
defaultHexTemplate.md ← built-in hex note template
hex-map/
HexMapView.ts ← interactive hex grid (ItemView)
HexEditorModal.ts ← right-click hex editor (Modal)
TerrainPickerModal.ts ← terrain palette picker
TerrainEntryEditorModal.ts ← edit a single terrain entry
IconPickerModal.ts ← icon override picker
RegionModal.ts ← region management
PathPickerModal.ts ← path type picker
PathTypeEditorModal.ts ← edit a single path type
FileLinkSuggestModal.ts ← file picker scoped to a folder
hex-table/
HexTableView.ts ← hex reference table (ItemView)
HexCellModal.ts ← inline cell editor
HexTerrainPickerModal.ts ← terrain picker in table view
random-tables/
RandomTableView.ts ← random tables + workflows panel (ItemView)
RandomTableModal.ts ← inline roll modal
RandomTableEditorModal.ts ← edit table entries
WorkflowEditorModal.ts ← edit workflow definition
WorkflowWizardModal.ts ← execute a workflow
randomTable.ts ← parse/roll/odds utilities
workflow.ts ← workflow parse/serialize utilities
```
### Troubleshooting
- **"Failed to load plugin"** — `main.js` is missing. Run `npm run build`.
- **Viewing logs** — Press `Ctrl+Shift+I` (Windows/Linux) or `Cmd+Option+I` (Mac) to open the developer console.
---
## Third-party libraries
This plugin bundles [MiniSearch](https://github.com/lucaong/minisearch) (© Luca Ongaro, MIT License) for in-memory full-text search.

View file

@ -1 +1 @@
export { default } from "./src/HexmakerPlugin";
export { default } from "./src/HexmakerPlugin";

View file

@ -1,6 +1,6 @@
{
"name": "hexmaker-plugin",
"version": "1.0.17",
"version": "1.0.18",
"description": "A plugin to power hex crawl worldbuilding and running.",
"main": "main.js",
"scripts": {

View file

@ -1,44 +1,44 @@
import { App, Modal } from "obsidian";
/** Base class for all Hexmaker modals. Provides shared behaviour. */
export class HexmakerModal extends Modal {
constructor(app: App) {
super(app);
}
/** Make this modal draggable by its title-bar area. Safe to call multiple times. */
protected makeDraggable(): void {
const modalEl = this.modalEl;
if (modalEl.dataset.draggable) return;
modalEl.dataset.draggable = "1";
modalEl.addClass("duckmage-editor-modal-drag");
modalEl.setCssProps({
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
margin: '0',
});
modalEl.addEventListener("mousedown", (e: MouseEvent) => {
const modalContent = modalEl.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 = modalEl.getBoundingClientRect();
modalEl.setCssProps({ transform: 'none', left: `${r.left}px`, top: `${r.top}px` });
const sx = e.clientX, sy = e.clientY;
const ox = r.left, oy = r.top;
const onMove = (ev: MouseEvent) => {
modalEl.setCssProps({ left: `${ox + ev.clientX - sx}px`, top: `${oy + ev.clientY - sy}px` });
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
}
}
import { App, Modal } from "obsidian";
/** Base class for all Hexmaker modals. Provides shared behaviour. */
export class HexmakerModal extends Modal {
constructor(app: App) {
super(app);
}
/** Make this modal draggable by its title-bar area. Safe to call multiple times. */
protected makeDraggable(): void {
const modalEl = this.modalEl;
if (modalEl.dataset.draggable) return;
modalEl.dataset.draggable = "1";
modalEl.addClass("duckmage-editor-modal-drag");
modalEl.setCssProps({
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
margin: '0',
});
modalEl.addEventListener("mousedown", (e: MouseEvent) => {
const modalContent = modalEl.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 = modalEl.getBoundingClientRect();
modalEl.setCssProps({ transform: 'none', left: `${r.left}px`, top: `${r.top}px` });
const sx = e.clientX, sy = e.clientY;
const ox = r.left, oy = r.top;
const onMove = (ev: MouseEvent) => {
modalEl.setCssProps({ left: `${ox + ev.clientX - sx}px`, top: `${oy + ev.clientY - sy}px` });
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
}
}

View file

@ -136,15 +136,17 @@ export default class HexmakerPlugin extends Plugin {
| undefined
)?.["linkedFolder"];
if (!lf || typeof lf !== "string") continue;
const lfNorm = normalizeFolder(lf);
const wikiLinkMatch = /^\[\[(.+?)\]\]$/.exec(lf);
const lfClean = wikiLinkMatch ? wikiLinkMatch[1].trim() : lf;
const lfNorm = normalizeFolder(lfClean);
if (lfNorm !== oldFolder && !lfNorm.startsWith(oldFolder + "/"))
continue;
const updatedLf = newFolder + lfNorm.slice(oldFolder.length);
await this.app.fileManager.processFrontMatter(
tableFile,
(fm: Frontmatter) => {
fm["linkedFolder"] = updatedLf;
},
await this.app.vault.process(tableFile, (content) =>
content.replace(
/^(linkedFolder:\s*).*$/m,
`$1"[[${updatedLf}]]"`,
),
);
}
}),

View file

@ -1,327 +1,327 @@
import type { TerrainColor, HexmakerPluginSettings, PathType } from "./types";
export const DEFAULT_PALETTE_NAME = "Default"; // kept for migration of legacy saves
export const LIMITED_PALETTE_NAME = "Limited";
export const EXPANDED_PALETTE_NAME = "Expanded";
export const VIEW_TYPE_HEX_MAP = "duckmage-hex-map";
export const VIEW_TYPE_HEX_TABLE = "duckmage-hex-table";
export const VIEW_TYPE_RANDOM_TABLES = "duckmage-random-tables";
export const DEFAULT_TERRAIN_PALETTE: TerrainColor[] = [
// Sea
{ name: "trench", color: "#14223d", category: "sea" },
{ name: "ocean", color: "#29507f", category: "sea" },
{ name: "shallows", color: "#4a82a5", category: "sea" },
// Uncategorised
{ name: "water", color: "#60a5fa" },
{ name: "urban", color: "#888888" },
// Lowlands
{
name: "grass",
color: "#69a168",
icon: "bw-grassland.png",
category: "lowlands",
},
{
name: "hills",
color: "#e0e8a1",
icon: "bw-hills.png",
category: "lowlands",
},
{
name: "foothills",
color: "#81c191",
icon: "bw-hills.png",
category: "lowlands",
},
// Snow
{ name: "snow", color: "#e0f2fe", category: "snow" },
{
name: "mountains snow",
color: "#bfdbfe",
icon: "bw-mountains-snow.png",
category: "snow",
},
// Desert
{ name: "desert", color: "#ecdba2", category: "desert" },
{
name: "desert rocky",
color: "#e6bc60",
icon: "bw-desert-rocky.png",
category: "desert",
},
{ name: "dunes", color: "#eccd7e", icon: "bw-dunes.png", category: "desert" },
{
name: "cactus",
color: "#e2b75a",
icon: "bw-cactus.png",
category: "desert",
},
{
name: "cactus heavy",
color: "#ddb869",
icon: "bw-cactus-heavy.png",
category: "desert",
},
{
name: "badlands",
color: "#c2410c",
icon: "bw-badlands.png",
category: "desert",
},
{
name: "brokenlands",
color: "#92400e",
icon: "bw-brokenlands.png",
category: "desert",
},
// Forest
{
name: "forest",
color: "#2d9553",
icon: "bw-forest.png",
category: "forest",
},
{
name: "forest heavy",
color: "#15803d",
icon: "bw-forest-heavy.png",
category: "forest",
},
{
name: "forested hills",
color: "#22c55e",
icon: "bw-forested-hills.png",
category: "forest",
},
{
name: "forested mountain",
color: "#6b9e7c",
icon: "bw-forested-mountain.png",
category: "forest",
},
{
name: "forested mountains",
color: "#466d46",
icon: "bw-forested-mountains.png",
iconColor: "#1b1d1c",
category: "forest",
},
{
name: "mixed forest",
color: "#16a34a",
icon: "bw-forest-mixed.png",
category: "forest",
},
{
name: "mixed forest heavy",
color: "#15803d",
icon: "bw-forest-mixed-heavy.png",
category: "forest",
},
{
name: "mixed forest hills",
color: "#22c55e",
icon: "bw-forest-mixed-hills.png",
category: "forest",
},
{
name: "mixed forest mountain",
color: "#6b9e7c",
icon: "bw-forest-mixed-mountain.png",
category: "forest",
},
{
name: "mixed forest mountains",
color: "#5e8c6a",
icon: "bw-forest-mixed-mountains.png",
category: "forest",
},
// Darkwood (evergreen)
{
name: "evergreen",
color: "#428a5e",
icon: "bw-evergreen.png",
category: "darkwood",
},
{
name: "evergreen heavy",
color: "#257445",
icon: "bw-evergreen-heavy.png",
iconColor: "#1f1e1e",
category: "darkwood",
},
{
name: "evergreen hills",
color: "#328651",
icon: "bw-evergreen-hills.png",
iconColor: "#292929",
category: "darkwood",
},
{
name: "evergreen mountain",
color: "#6b806b",
icon: "bw-evergreen-mountain.png",
category: "darkwood",
},
{
name: "evergreen mountains",
color: "#8c9d80",
icon: "bw-evergreen-mountains.png",
category: "darkwood",
},
// Island (jungle / volcanic)
{
name: "jungle",
color: "#15803d",
icon: "bw-jungle.png",
category: "island",
},
{
name: "jungle heavy",
color: "#14532d",
icon: "bw-jungle-heavy.png",
iconColor: "#ffffff",
category: "island",
},
{
name: "jungle hills",
color: "#4ade80",
icon: "bw-jungle-hills.png",
category: "island",
},
{
name: "jungle mountain",
color: "#4d7c0f",
icon: "bw-jungle-mountain.png",
category: "island",
},
{
name: "jungle mountains",
color: "#3f6212",
icon: "bw-jungle-mountains.png",
category: "island",
},
{
name: "volcano",
color: "#b91c1c",
icon: "bw-volcano.png",
category: "island",
},
{
name: "volcano dormant",
color: "#78350f",
icon: "bw-volcano-dormant.png",
iconColor: "#ffffff",
category: "island",
},
// Mountain
{
name: "cliffs",
color: "#a86f1f",
icon: "bw-brokenlands.png",
category: "mountain",
},
{
name: "mountain",
color: "#a77649",
icon: "bw-mountain.png",
category: "mountain",
},
{
name: "mountain ridge",
color: "#c55f0d",
icon: "bw-mountains.png",
category: "mountain",
},
{
name: "peak",
color: "#78716c",
icon: "bw-mountain.png",
category: "mountain",
},
// Bog (wetlands)
{ name: "marsh", color: "#909f23", icon: "bw-marsh.png", category: "bog" },
{
name: "swamp",
color: "#4e5214",
icon: "bw-swamp.png",
iconColor: "#f9fbf9",
category: "bog",
},
{
name: "bog",
color: "#432e6b",
icon: "bw-swamp.png",
iconColor: "#ffffff",
category: "bog",
},
// Coast
{
name: "beach",
color: "#cac181",
icon: "bw-grassland.png",
category: "coast",
},
{
name: "salt flats",
color: "#f7eaba",
icon: "bw-dunes.png",
category: "coast",
},
];
export const LIMITED_TERRAIN_PALETTE: TerrainColor[] = [
{ name: "ocean", color: "#29507f", category: "sea" },
{ name: "grass", color: "#69a168", icon: "bw-grassland.png", category: "lowlands" },
{ name: "hill", color: "#e0e8a1", icon: "bw-hills.png", category: "lowlands" },
{ name: "forest", color: "#2d9553", icon: "bw-forest.png", category: "forest" },
{ name: "mountain", color: "#a77649", icon: "bw-mountain.png", category: "mountain" },
{ name: "desert", color: "#ecdba2", category: "desert" },
{ name: "snow", color: "#e0f2fe", category: "snow" },
];
export const DEFAULT_PATH_TYPES: PathType[] = [
{ name: "Road", color: "#a16207", width: 4, lineStyle: "solid", routing: "through" },
{ name: "River", color: "#3b82f6", width: 3, lineStyle: "solid", routing: "meander" },
];
export const DEFAULT_SETTINGS: HexmakerPluginSettings = {
mySetting: "default",
worldFolder: "world",
hexFolder: "world/hexes",
townsFolder: "",
dungeonsFolder: "",
questsFolder: "",
featuresFolder: "",
iconsFolder: "",
templatePath: "",
hexGap: "0.15",
terrainPalettes: [
{ name: LIMITED_PALETTE_NAME, terrains: LIMITED_TERRAIN_PALETTE },
{ name: EXPANDED_PALETTE_NAME, terrains: DEFAULT_TERRAIN_PALETTE },
],
regions: [
{
name: "default",
paletteName: LIMITED_PALETTE_NAME,
gridSize: { cols: 20, rows: 16 },
gridOffset: { x: 0, y: 0 },
pathChains: [],
},
],
zoomLevel: 1,
pathTypes: DEFAULT_PATH_TYPES,
hexOrientation: "flat",
tablesFolder: "world/tables",
factionsFolder: "",
defaultTableDice: 100,
hexEditorTerrainCollapsed: false,
hexEditorFeaturesCollapsed: false,
hexEditorNotesCollapsed: false,
rollTableExcludedFolders: ["terrain"],
encounterTableExcludedFolders: ["terrain"],
defaultRegion: "default",
workflowsFolder: "",
};
import type { TerrainColor, HexmakerPluginSettings, PathType } from "./types";
export const DEFAULT_PALETTE_NAME = "Default"; // kept for migration of legacy saves
export const LIMITED_PALETTE_NAME = "Limited";
export const EXPANDED_PALETTE_NAME = "Expanded";
export const VIEW_TYPE_HEX_MAP = "duckmage-hex-map";
export const VIEW_TYPE_HEX_TABLE = "duckmage-hex-table";
export const VIEW_TYPE_RANDOM_TABLES = "duckmage-random-tables";
export const DEFAULT_TERRAIN_PALETTE: TerrainColor[] = [
// Sea
{ name: "trench", color: "#14223d", category: "sea" },
{ name: "ocean", color: "#29507f", category: "sea" },
{ name: "shallows", color: "#4a82a5", category: "sea" },
// Uncategorised
{ name: "water", color: "#60a5fa" },
{ name: "urban", color: "#888888" },
// Lowlands
{
name: "grass",
color: "#69a168",
icon: "bw-grassland.png",
category: "lowlands",
},
{
name: "hills",
color: "#e0e8a1",
icon: "bw-hills.png",
category: "lowlands",
},
{
name: "foothills",
color: "#81c191",
icon: "bw-hills.png",
category: "lowlands",
},
// Snow
{ name: "snow", color: "#e0f2fe", category: "snow" },
{
name: "mountains snow",
color: "#bfdbfe",
icon: "bw-mountains-snow.png",
category: "snow",
},
// Desert
{ name: "desert", color: "#ecdba2", category: "desert" },
{
name: "desert rocky",
color: "#e6bc60",
icon: "bw-desert-rocky.png",
category: "desert",
},
{ name: "dunes", color: "#eccd7e", icon: "bw-dunes.png", category: "desert" },
{
name: "cactus",
color: "#e2b75a",
icon: "bw-cactus.png",
category: "desert",
},
{
name: "cactus heavy",
color: "#ddb869",
icon: "bw-cactus-heavy.png",
category: "desert",
},
{
name: "badlands",
color: "#c2410c",
icon: "bw-badlands.png",
category: "desert",
},
{
name: "brokenlands",
color: "#92400e",
icon: "bw-brokenlands.png",
category: "desert",
},
// Forest
{
name: "forest",
color: "#2d9553",
icon: "bw-forest.png",
category: "forest",
},
{
name: "forest heavy",
color: "#15803d",
icon: "bw-forest-heavy.png",
category: "forest",
},
{
name: "forested hills",
color: "#22c55e",
icon: "bw-forested-hills.png",
category: "forest",
},
{
name: "forested mountain",
color: "#6b9e7c",
icon: "bw-forested-mountain.png",
category: "forest",
},
{
name: "forested mountains",
color: "#466d46",
icon: "bw-forested-mountains.png",
iconColor: "#1b1d1c",
category: "forest",
},
{
name: "mixed forest",
color: "#16a34a",
icon: "bw-forest-mixed.png",
category: "forest",
},
{
name: "mixed forest heavy",
color: "#15803d",
icon: "bw-forest-mixed-heavy.png",
category: "forest",
},
{
name: "mixed forest hills",
color: "#22c55e",
icon: "bw-forest-mixed-hills.png",
category: "forest",
},
{
name: "mixed forest mountain",
color: "#6b9e7c",
icon: "bw-forest-mixed-mountain.png",
category: "forest",
},
{
name: "mixed forest mountains",
color: "#5e8c6a",
icon: "bw-forest-mixed-mountains.png",
category: "forest",
},
// Darkwood (evergreen)
{
name: "evergreen",
color: "#428a5e",
icon: "bw-evergreen.png",
category: "darkwood",
},
{
name: "evergreen heavy",
color: "#257445",
icon: "bw-evergreen-heavy.png",
iconColor: "#1f1e1e",
category: "darkwood",
},
{
name: "evergreen hills",
color: "#328651",
icon: "bw-evergreen-hills.png",
iconColor: "#292929",
category: "darkwood",
},
{
name: "evergreen mountain",
color: "#6b806b",
icon: "bw-evergreen-mountain.png",
category: "darkwood",
},
{
name: "evergreen mountains",
color: "#8c9d80",
icon: "bw-evergreen-mountains.png",
category: "darkwood",
},
// Island (jungle / volcanic)
{
name: "jungle",
color: "#15803d",
icon: "bw-jungle.png",
category: "island",
},
{
name: "jungle heavy",
color: "#14532d",
icon: "bw-jungle-heavy.png",
iconColor: "#ffffff",
category: "island",
},
{
name: "jungle hills",
color: "#4ade80",
icon: "bw-jungle-hills.png",
category: "island",
},
{
name: "jungle mountain",
color: "#4d7c0f",
icon: "bw-jungle-mountain.png",
category: "island",
},
{
name: "jungle mountains",
color: "#3f6212",
icon: "bw-jungle-mountains.png",
category: "island",
},
{
name: "volcano",
color: "#b91c1c",
icon: "bw-volcano.png",
category: "island",
},
{
name: "volcano dormant",
color: "#78350f",
icon: "bw-volcano-dormant.png",
iconColor: "#ffffff",
category: "island",
},
// Mountain
{
name: "cliffs",
color: "#a86f1f",
icon: "bw-brokenlands.png",
category: "mountain",
},
{
name: "mountain",
color: "#a77649",
icon: "bw-mountain.png",
category: "mountain",
},
{
name: "mountain ridge",
color: "#c55f0d",
icon: "bw-mountains.png",
category: "mountain",
},
{
name: "peak",
color: "#78716c",
icon: "bw-mountain.png",
category: "mountain",
},
// Bog (wetlands)
{ name: "marsh", color: "#909f23", icon: "bw-marsh.png", category: "bog" },
{
name: "swamp",
color: "#4e5214",
icon: "bw-swamp.png",
iconColor: "#f9fbf9",
category: "bog",
},
{
name: "bog",
color: "#432e6b",
icon: "bw-swamp.png",
iconColor: "#ffffff",
category: "bog",
},
// Coast
{
name: "beach",
color: "#cac181",
icon: "bw-grassland.png",
category: "coast",
},
{
name: "salt flats",
color: "#f7eaba",
icon: "bw-dunes.png",
category: "coast",
},
];
export const LIMITED_TERRAIN_PALETTE: TerrainColor[] = [
{ name: "ocean", color: "#29507f", category: "sea" },
{ name: "grass", color: "#69a168", icon: "bw-grassland.png", category: "lowlands" },
{ name: "hill", color: "#e0e8a1", icon: "bw-hills.png", category: "lowlands" },
{ name: "forest", color: "#2d9553", icon: "bw-forest.png", category: "forest" },
{ name: "mountain", color: "#a77649", icon: "bw-mountain.png", category: "mountain" },
{ name: "desert", color: "#ecdba2", category: "desert" },
{ name: "snow", color: "#e0f2fe", category: "snow" },
];
export const DEFAULT_PATH_TYPES: PathType[] = [
{ name: "Road", color: "#a16207", width: 4, lineStyle: "solid", routing: "through" },
{ name: "River", color: "#3b82f6", width: 3, lineStyle: "solid", routing: "meander" },
];
export const DEFAULT_SETTINGS: HexmakerPluginSettings = {
mySetting: "default",
worldFolder: "world",
hexFolder: "world/hexes",
townsFolder: "",
dungeonsFolder: "",
questsFolder: "",
featuresFolder: "",
iconsFolder: "",
templatePath: "",
hexGap: "0.15",
terrainPalettes: [
{ name: LIMITED_PALETTE_NAME, terrains: LIMITED_TERRAIN_PALETTE },
{ name: EXPANDED_PALETTE_NAME, terrains: DEFAULT_TERRAIN_PALETTE },
],
regions: [
{
name: "default",
paletteName: LIMITED_PALETTE_NAME,
gridSize: { cols: 20, rows: 16 },
gridOffset: { x: 0, y: 0 },
pathChains: [],
},
],
zoomLevel: 1,
pathTypes: DEFAULT_PATH_TYPES,
hexOrientation: "flat",
tablesFolder: "world/tables",
factionsFolder: "",
defaultTableDice: 100,
hexEditorTerrainCollapsed: false,
hexEditorFeaturesCollapsed: false,
hexEditorNotesCollapsed: false,
rollTableExcludedFolders: ["terrain"],
encounterTableExcludedFolders: ["terrain"],
defaultRegion: "default",
workflowsFolder: "",
};

View file

@ -1,38 +1,38 @@
import { App, SuggestModal, TFile } from "obsidian";
import type HexmakerPlugin from "../HexmakerPlugin";
import { normalizeFolder } from "../utils";
export class FileLinkSuggestModal extends SuggestModal<TFile> {
constructor(
app: App,
private plugin: HexmakerPlugin,
private onChoose: (file: TFile) => void,
) {
super(app);
this.setPlaceholder("Search for a file to link...");
}
getSuggestions(query: string): TFile[] {
const rootFolder = normalizeFolder(this.plugin.settings.worldFolder?.trim() ?? "");
let files: TFile[];
if (rootFolder) {
files = this.app.vault.getFiles().filter(
f => f.path.startsWith(rootFolder + "/") || f.path === rootFolder,
);
} else {
files = this.app.vault.getFiles();
}
return files
.filter(f => f.basename.toLowerCase().contains(query.toLowerCase()))
.sort((a, b) => a.basename.localeCompare(b.basename));
}
renderSuggestion(file: TFile, el: HTMLElement): void {
el.createSpan({ text: file.basename });
el.createEl("small", { text: `${file.path}`, cls: "duckmage-suggestion-path" });
}
onChooseSuggestion(file: TFile, _evt: MouseEvent | KeyboardEvent): void {
this.onChoose(file);
}
}
import { App, SuggestModal, TFile } from "obsidian";
import type HexmakerPlugin from "../HexmakerPlugin";
import { normalizeFolder } from "../utils";
export class FileLinkSuggestModal extends SuggestModal<TFile> {
constructor(
app: App,
private plugin: HexmakerPlugin,
private onChoose: (file: TFile) => void,
) {
super(app);
this.setPlaceholder("Search for a file to link...");
}
getSuggestions(query: string): TFile[] {
const rootFolder = normalizeFolder(this.plugin.settings.worldFolder?.trim() ?? "");
let files: TFile[];
if (rootFolder) {
files = this.app.vault.getFiles().filter(
f => f.path.startsWith(rootFolder + "/") || f.path === rootFolder,
);
} else {
files = this.app.vault.getFiles();
}
return files
.filter(f => f.basename.toLowerCase().contains(query.toLowerCase()))
.sort((a, b) => a.basename.localeCompare(b.basename));
}
renderSuggestion(file: TFile, el: HTMLElement): void {
el.createSpan({ text: file.basename });
el.createEl("small", { text: `${file.path}`, cls: "duckmage-suggestion-path" });
}
onChooseSuggestion(file: TFile, _evt: MouseEvent | KeyboardEvent): void {
this.onChoose(file);
}
}

View file

@ -1,52 +1,52 @@
import { App } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import { getIconUrl } from "../utils";
export class IconPickerModal extends HexmakerModal {
constructor(
app: App,
private plugin: HexmakerPlugin,
private onSelect: (iconName: string | null) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
this.makeDraggable();
contentEl.createEl("h2", { text: "Paint icon" });
const section = contentEl.createDiv({ cls: "duckmage-editor-section" });
const grid = section.createDiv({ cls: "duckmage-icon-picker" });
// Remove icon option
const clearBtn = grid.createDiv({ cls: "duckmage-icon-option" });
clearBtn.createDiv({ cls: "duckmage-icon-preview duckmage-icon-preview-clear" });
clearBtn.createSpan({ text: "Remove", cls: "duckmage-icon-option-name" });
clearBtn.addEventListener("click", () => {
this.onSelect(null);
this.close();
});
for (const icon of this.plugin.availableIcons) {
const label = icon.replace(/^bw-/, "").replace(/\.(png|jpg|jpeg|gif|svg|webp)$/i, "").replace(/-/g, " ");
const btn = grid.createDiv({ cls: "duckmage-icon-option" });
const preview = btn.createDiv({ cls: "duckmage-icon-preview" });
const img = preview.createEl("img", { cls: "duckmage-icon-preview-img" });
img.src = getIconUrl(this.plugin, icon);
img.alt = label;
btn.createSpan({ text: label, cls: "duckmage-icon-option-name" });
btn.addEventListener("click", () => {
this.onSelect(icon);
this.close();
});
}
}
onClose(): void {
this.contentEl.empty();
}
}
import { App } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import { getIconUrl } from "../utils";
export class IconPickerModal extends HexmakerModal {
constructor(
app: App,
private plugin: HexmakerPlugin,
private onSelect: (iconName: string | null) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
this.makeDraggable();
contentEl.createEl("h2", { text: "Paint icon" });
const section = contentEl.createDiv({ cls: "duckmage-editor-section" });
const grid = section.createDiv({ cls: "duckmage-icon-picker" });
// Remove icon option
const clearBtn = grid.createDiv({ cls: "duckmage-icon-option" });
clearBtn.createDiv({ cls: "duckmage-icon-preview duckmage-icon-preview-clear" });
clearBtn.createSpan({ text: "Remove", cls: "duckmage-icon-option-name" });
clearBtn.addEventListener("click", () => {
this.onSelect(null);
this.close();
});
for (const icon of this.plugin.availableIcons) {
const label = icon.replace(/^bw-/, "").replace(/\.(png|jpg|jpeg|gif|svg|webp)$/i, "").replace(/-/g, " ");
const btn = grid.createDiv({ cls: "duckmage-icon-option" });
const preview = btn.createDiv({ cls: "duckmage-icon-preview" });
const img = preview.createEl("img", { cls: "duckmage-icon-preview-img" });
img.src = getIconUrl(this.plugin, icon);
img.alt = label;
btn.createSpan({ text: label, cls: "duckmage-icon-option-name" });
btn.addEventListener("click", () => {
this.onSelect(icon);
this.close();
});
}
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -1,246 +1,246 @@
import { App } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import type { PathType } from "../types";
import { PathTypeEditorModal } from "./PathTypeEditorModal";
// ── Mini hex SVG preview for a path type ─────────────────────────────────────
export function buildPathPreviewSvg(pt: PathType): SVGElement {
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("width", "36");
svg.setAttribute("height", "36");
svg.setAttribute("viewBox", "0 0 36 36");
// Hex polygon: radius 14, center (18,18), starting at 0° (right vertex)
// Vertices: V0(32,18) right, V1(25,30) lower-right, V2(11,30) lower-left,
// V3(4,18) left, V4(11,6) upper-left, V5(25,6) upper-right
const r = 14;
const cx = 18, cy = 18;
const hexPts: string[] = [];
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 180) * (60 * i);
hexPts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
}
const hex = document.createElementNS(svgNS, "polygon");
hex.setAttribute("points", hexPts.join(" "));
hex.setAttribute("fill", "var(--background-secondary)");
hex.setAttribute("stroke", "var(--background-modifier-border)");
hex.setAttribute("stroke-width", "1");
svg.appendChild(hex);
// Path line — shape depends on routing mode
const DASH_ARRAYS: Record<string, string> = { solid: "", dashed: "4 2", dotted: "1.5 2" };
const strokeW = String(Math.max(1, Math.min(pt.width, 4)) * 0.6);
const dash = DASH_ARRAYS[pt.lineStyle] ?? "";
let pathD: string;
if (pt.routing === "edge") {
// One vertex per shared edge: enters from left, hugs the lower-left boundary vertex,
// crosses to the lower-right boundary vertex, exits right — one corner per hex.
pathD = "M 4 18 L 11 30 L 32 18";
} else if (pt.routing === "meander") {
// Gentle curve through the lower third of the hex
pathD = "M 4 18 Q 18 26 32 18";
} else {
// "through": straight line through hex center
pathD = "M 4 18 L 32 18";
}
const pathEl = document.createElementNS(svgNS, "path");
pathEl.setAttribute("d", pathD);
pathEl.setAttribute("stroke", pt.color);
pathEl.setAttribute("stroke-width", strokeW);
pathEl.setAttribute("stroke-linecap", "round");
pathEl.setAttribute("stroke-linejoin", "round");
pathEl.setAttribute("fill", "none");
if (dash) pathEl.setAttribute("stroke-dasharray", dash);
svg.appendChild(pathEl);
return svg;
}
// ── Modal ─────────────────────────────────────────────────────────────────────
export class PathPickerModal extends HexmakerModal {
private editMode = false;
private editChanged = false;
private selectionMade = false;
constructor(
app: App,
private plugin: HexmakerPlugin,
private currentTypeName: string | null,
private onSelect: (typeName: string) => void,
private onDismiss?: () => void,
) {
super(app);
}
onOpen(): void {
this.makeDraggable();
this.render();
}
onClose(): void {
if (this.editChanged) {
this.plugin.refreshHexMap();
}
if (!this.selectionMade) {
this.onDismiss?.();
}
this.contentEl.empty();
}
private render(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
const header = contentEl.createDiv({ cls: "duckmage-tpe-header" });
header.createEl("h2", {
text: this.editMode ? "Edit path types" : "Select path type",
});
const toggleBtn = header.createEl("button", {
cls: "duckmage-tpe-edit-btn",
text: this.editMode ? "← Done" : "✏ Edit",
});
toggleBtn.addEventListener("click", () => {
this.editMode = !this.editMode;
this.render();
});
if (this.editMode) {
this.renderEditMode();
} else {
this.renderPickMode();
}
}
private renderPickMode(): void {
const { contentEl } = this;
const pathTypes = this.plugin.settings.pathTypes;
const section = contentEl.createDiv({ cls: "duckmage-editor-section" });
const grid = section.createDiv({
cls: "duckmage-terrain-picker duckmage-terrain-picker-full",
});
for (const pt of pathTypes) {
const btn = grid.createDiv({ cls: "duckmage-terrain-option" });
btn.toggleClass("is-selected", pt.name === this.currentTypeName);
const preview = btn.createDiv({ cls: "duckmage-path-preview" });
preview.appendChild(buildPathPreviewSvg(pt));
btn.createSpan({ text: pt.name, cls: "duckmage-terrain-option-name" });
btn.addEventListener("click", () => {
this.selectionMade = true;
this.currentTypeName = pt.name;
this.onSelect(pt.name);
this.close();
});
}
if (pathTypes.length === 0) {
grid.createEl("p", { text: "No path types defined. Switch to edit mode to add one.", cls: "duckmage-tpe-empty" });
}
}
private renderEditMode(): void {
const { contentEl } = this;
const pathTypes = this.plugin.settings.pathTypes;
const grid = contentEl.createDiv({
cls: "duckmage-terrain-picker duckmage-terrain-picker-full",
});
let dragSrcIndex = -1;
const renderTiles = () => {
grid.empty();
for (let i = 0; i < pathTypes.length; i++) {
const pt = pathTypes[i];
const tile = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-editable",
});
tile.draggable = true;
tile.createSpan({ cls: "duckmage-terrain-edit-grip", text: "⠿" });
tile.createSpan({ cls: "duckmage-terrain-edit-pencil", text: "✏" });
tile.addEventListener("click", () => {
new PathTypeEditorModal(
this.app,
this.plugin,
pt,
() => { this.editChanged = true; renderTiles(); },
() => { this.editChanged = true; renderTiles(); },
).open();
});
const preview = tile.createDiv({ cls: "duckmage-path-preview" });
preview.appendChild(buildPathPreviewSvg(pt));
tile.createSpan({ text: pt.name, cls: "duckmage-terrain-option-name" });
// Drag-to-reorder
tile.addEventListener("dragstart", (e: DragEvent) => {
dragSrcIndex = i;
tile.addClass("duckmage-palette-dragging");
e.dataTransfer?.setDragImage(tile, 0, 0);
});
tile.addEventListener("dragend", () => {
tile.removeClass("duckmage-palette-dragging");
grid
.querySelectorAll(".duckmage-palette-drop-target")
.forEach((el) => el.classList.remove("duckmage-palette-drop-target"));
});
tile.addEventListener("dragover", (e: DragEvent) => {
e.preventDefault();
grid
.querySelectorAll(".duckmage-palette-drop-target")
.forEach((el) => el.classList.remove("duckmage-palette-drop-target"));
tile.addClass("duckmage-palette-drop-target");
});
tile.addEventListener("drop", (e: DragEvent) => {
e.preventDefault();
if (dragSrcIndex === -1 || dragSrcIndex === i) return;
const [moved] = pathTypes.splice(dragSrcIndex, 1);
pathTypes.splice(i, 0, moved);
dragSrcIndex = -1;
this.editChanged = true;
void this.plugin.saveSettings().then(() => renderTiles());
});
}
// "+" add tile
const addTile = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-add",
});
addTile
.createDiv({ cls: "duckmage-terrain-preview duckmage-terrain-preview-add" })
.setText("+");
addTile.createSpan({ text: "Add", cls: "duckmage-terrain-option-name" });
addTile.addEventListener("click", () => {
void (async () => {
const newPt: PathType = { name: "New path", color: "#888888", width: 3, lineStyle: "solid", routing: "through" };
pathTypes.push(newPt);
this.editChanged = true;
await this.plugin.saveSettings();
renderTiles();
new PathTypeEditorModal(
this.app,
this.plugin,
newPt,
() => { this.editChanged = true; renderTiles(); },
() => { this.editChanged = true; renderTiles(); },
).open();
})();
});
};
renderTiles();
}
}
import { App } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import type { PathType } from "../types";
import { PathTypeEditorModal } from "./PathTypeEditorModal";
// ── Mini hex SVG preview for a path type ─────────────────────────────────────
export function buildPathPreviewSvg(pt: PathType): SVGElement {
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("width", "36");
svg.setAttribute("height", "36");
svg.setAttribute("viewBox", "0 0 36 36");
// Hex polygon: radius 14, center (18,18), starting at 0° (right vertex)
// Vertices: V0(32,18) right, V1(25,30) lower-right, V2(11,30) lower-left,
// V3(4,18) left, V4(11,6) upper-left, V5(25,6) upper-right
const r = 14;
const cx = 18, cy = 18;
const hexPts: string[] = [];
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 180) * (60 * i);
hexPts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
}
const hex = document.createElementNS(svgNS, "polygon");
hex.setAttribute("points", hexPts.join(" "));
hex.setAttribute("fill", "var(--background-secondary)");
hex.setAttribute("stroke", "var(--background-modifier-border)");
hex.setAttribute("stroke-width", "1");
svg.appendChild(hex);
// Path line — shape depends on routing mode
const DASH_ARRAYS: Record<string, string> = { solid: "", dashed: "4 2", dotted: "1.5 2" };
const strokeW = String(Math.max(1, Math.min(pt.width, 4)) * 0.6);
const dash = DASH_ARRAYS[pt.lineStyle] ?? "";
let pathD: string;
if (pt.routing === "edge") {
// One vertex per shared edge: enters from left, hugs the lower-left boundary vertex,
// crosses to the lower-right boundary vertex, exits right — one corner per hex.
pathD = "M 4 18 L 11 30 L 32 18";
} else if (pt.routing === "meander") {
// Gentle curve through the lower third of the hex
pathD = "M 4 18 Q 18 26 32 18";
} else {
// "through": straight line through hex center
pathD = "M 4 18 L 32 18";
}
const pathEl = document.createElementNS(svgNS, "path");
pathEl.setAttribute("d", pathD);
pathEl.setAttribute("stroke", pt.color);
pathEl.setAttribute("stroke-width", strokeW);
pathEl.setAttribute("stroke-linecap", "round");
pathEl.setAttribute("stroke-linejoin", "round");
pathEl.setAttribute("fill", "none");
if (dash) pathEl.setAttribute("stroke-dasharray", dash);
svg.appendChild(pathEl);
return svg;
}
// ── Modal ─────────────────────────────────────────────────────────────────────
export class PathPickerModal extends HexmakerModal {
private editMode = false;
private editChanged = false;
private selectionMade = false;
constructor(
app: App,
private plugin: HexmakerPlugin,
private currentTypeName: string | null,
private onSelect: (typeName: string) => void,
private onDismiss?: () => void,
) {
super(app);
}
onOpen(): void {
this.makeDraggable();
this.render();
}
onClose(): void {
if (this.editChanged) {
this.plugin.refreshHexMap();
}
if (!this.selectionMade) {
this.onDismiss?.();
}
this.contentEl.empty();
}
private render(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
const header = contentEl.createDiv({ cls: "duckmage-tpe-header" });
header.createEl("h2", {
text: this.editMode ? "Edit path types" : "Select path type",
});
const toggleBtn = header.createEl("button", {
cls: "duckmage-tpe-edit-btn",
text: this.editMode ? "← Done" : "✏ Edit",
});
toggleBtn.addEventListener("click", () => {
this.editMode = !this.editMode;
this.render();
});
if (this.editMode) {
this.renderEditMode();
} else {
this.renderPickMode();
}
}
private renderPickMode(): void {
const { contentEl } = this;
const pathTypes = this.plugin.settings.pathTypes;
const section = contentEl.createDiv({ cls: "duckmage-editor-section" });
const grid = section.createDiv({
cls: "duckmage-terrain-picker duckmage-terrain-picker-full",
});
for (const pt of pathTypes) {
const btn = grid.createDiv({ cls: "duckmage-terrain-option" });
btn.toggleClass("is-selected", pt.name === this.currentTypeName);
const preview = btn.createDiv({ cls: "duckmage-path-preview" });
preview.appendChild(buildPathPreviewSvg(pt));
btn.createSpan({ text: pt.name, cls: "duckmage-terrain-option-name" });
btn.addEventListener("click", () => {
this.selectionMade = true;
this.currentTypeName = pt.name;
this.onSelect(pt.name);
this.close();
});
}
if (pathTypes.length === 0) {
grid.createEl("p", { text: "No path types defined. Switch to edit mode to add one.", cls: "duckmage-tpe-empty" });
}
}
private renderEditMode(): void {
const { contentEl } = this;
const pathTypes = this.plugin.settings.pathTypes;
const grid = contentEl.createDiv({
cls: "duckmage-terrain-picker duckmage-terrain-picker-full",
});
let dragSrcIndex = -1;
const renderTiles = () => {
grid.empty();
for (let i = 0; i < pathTypes.length; i++) {
const pt = pathTypes[i];
const tile = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-editable",
});
tile.draggable = true;
tile.createSpan({ cls: "duckmage-terrain-edit-grip", text: "⠿" });
tile.createSpan({ cls: "duckmage-terrain-edit-pencil", text: "✏" });
tile.addEventListener("click", () => {
new PathTypeEditorModal(
this.app,
this.plugin,
pt,
() => { this.editChanged = true; renderTiles(); },
() => { this.editChanged = true; renderTiles(); },
).open();
});
const preview = tile.createDiv({ cls: "duckmage-path-preview" });
preview.appendChild(buildPathPreviewSvg(pt));
tile.createSpan({ text: pt.name, cls: "duckmage-terrain-option-name" });
// Drag-to-reorder
tile.addEventListener("dragstart", (e: DragEvent) => {
dragSrcIndex = i;
tile.addClass("duckmage-palette-dragging");
e.dataTransfer?.setDragImage(tile, 0, 0);
});
tile.addEventListener("dragend", () => {
tile.removeClass("duckmage-palette-dragging");
grid
.querySelectorAll(".duckmage-palette-drop-target")
.forEach((el) => el.classList.remove("duckmage-palette-drop-target"));
});
tile.addEventListener("dragover", (e: DragEvent) => {
e.preventDefault();
grid
.querySelectorAll(".duckmage-palette-drop-target")
.forEach((el) => el.classList.remove("duckmage-palette-drop-target"));
tile.addClass("duckmage-palette-drop-target");
});
tile.addEventListener("drop", (e: DragEvent) => {
e.preventDefault();
if (dragSrcIndex === -1 || dragSrcIndex === i) return;
const [moved] = pathTypes.splice(dragSrcIndex, 1);
pathTypes.splice(i, 0, moved);
dragSrcIndex = -1;
this.editChanged = true;
void this.plugin.saveSettings().then(() => renderTiles());
});
}
// "+" add tile
const addTile = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-add",
});
addTile
.createDiv({ cls: "duckmage-terrain-preview duckmage-terrain-preview-add" })
.setText("+");
addTile.createSpan({ text: "Add", cls: "duckmage-terrain-option-name" });
addTile.addEventListener("click", () => {
void (async () => {
const newPt: PathType = { name: "New path", color: "#888888", width: 3, lineStyle: "solid", routing: "through" };
pathTypes.push(newPt);
this.editChanged = true;
await this.plugin.saveSettings();
renderTiles();
new PathTypeEditorModal(
this.app,
this.plugin,
newPt,
() => { this.editChanged = true; renderTiles(); },
() => { this.editChanged = true; renderTiles(); },
).open();
})();
});
};
renderTiles();
}
}

View file

@ -1,204 +1,204 @@
import { App, Setting } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import type { PathType, PathLineStyle, PathRouting } from "../types";
import { buildPathPreviewSvg } from "./PathPickerModal";
export class PathTypeEditorModal extends HexmakerModal {
private pendingName: string;
private pendingColor: string;
private pendingWidth: number;
private pendingLineStyle: PathLineStyle;
private pendingRouting: PathRouting;
private readonly originalName: string;
private savedOrDeleted = false;
constructor(
app: App,
private plugin: HexmakerPlugin,
private entry: PathType,
private onSave: () => void,
private onDelete: () => void,
) {
super(app);
this.originalName = entry.name;
this.pendingName = entry.name;
this.pendingColor = entry.color;
this.pendingWidth = entry.width;
this.pendingLineStyle = entry.lineStyle;
this.pendingRouting = entry.routing;
}
onOpen(): void {
this.makeDraggable();
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
contentEl.createEl("h2", { text: "Edit path type" });
// Live preview div
const previewDiv = contentEl.createDiv({ cls: "duckmage-path-preview duckmage-path-editor-preview" });
const redrawPreview = () => {
previewDiv.empty();
previewDiv.appendChild(buildPathPreviewSvg({
name: this.pendingName,
color: this.pendingColor,
width: this.pendingWidth,
lineStyle: this.pendingLineStyle,
routing: this.pendingRouting,
}));
};
redrawPreview();
new Setting(contentEl)
.setName("Name")
.addText(text =>
text
.setValue(this.pendingName)
.onChange(value => {
this.pendingName = value.trim() || this.pendingName;
}),
);
new Setting(contentEl)
.setName("Color")
.addColorPicker(color =>
color
.setValue(this.pendingColor)
.onChange(value => {
this.pendingColor = value;
redrawPreview();
}),
);
new Setting(contentEl)
.setName("Width")
.addSlider(slider =>
slider
.setLimits(1, 10, 1)
.setValue(this.pendingWidth)
.setDynamicTooltip()
.onChange(value => {
this.pendingWidth = value;
redrawPreview();
}),
);
new Setting(contentEl)
.setName("Line style")
.addDropdown(dd =>
dd
.addOption("solid", "Solid")
.addOption("dashed", "Dashed")
.addOption("dotted", "Dotted")
.setValue(this.pendingLineStyle)
.onChange(value => {
this.pendingLineStyle = value as PathLineStyle;
redrawPreview();
}),
);
new Setting(contentEl)
.setName("Routing")
.addDropdown(dd =>
dd
.addOption("through", "Through hex centers")
.addOption("meander", "Meander (curves via edge midpoints)")
.addOption("edge", "Along hex edges (tight boundary)")
.setValue(this.pendingRouting)
.onChange(value => {
this.pendingRouting = value as PathRouting;
redrawPreview();
}),
);
// Save button
const btnRow = contentEl.createDiv({ cls: "duckmage-terrain-editor-actions" });
btnRow.createEl("button", { text: "Save", cls: "mod-cta" }).addEventListener("click", () => {
void (async () => {
await this.doSave();
this.close();
})();
});
// Delete button with confirm
const deleteBtn = btnRow.createEl("button", { text: "Delete", cls: "mod-warning" });
let confirmDiv: HTMLElement | null = null;
deleteBtn.addEventListener("click", () => {
if (confirmDiv) { confirmDiv.remove(); confirmDiv = null; return; }
const chainCount = this.plugin.settings.regions.reduce(
(sum, r) => sum + r.pathChains.filter(c => c.typeName === this.originalName).length,
0,
);
confirmDiv = contentEl.createDiv({ cls: "duckmage-terrain-editor-confirm" });
if (chainCount > 0) {
confirmDiv.createEl("p", {
text: `This type is used in ${chainCount} chain(s). Deleting it will remove those chains. Continue?`,
cls: "duckmage-terrain-editor-confirm-msg",
});
} else {
confirmDiv.createEl("p", { text: "Delete this path type?", cls: "duckmage-terrain-editor-confirm-msg" });
}
confirmDiv.createEl("button", { text: "Yes, delete", cls: "mod-warning" }).addEventListener("click", () => {
void (async () => {
await this.doDelete();
this.close();
})();
});
confirmDiv.createEl("button", { text: "Cancel" }).addEventListener("click", () => {
confirmDiv?.remove();
confirmDiv = null;
});
});
}
onClose(): void {
if (!this.savedOrDeleted) {
void this.doSave();
}
this.contentEl.empty();
}
private async doSave(): Promise<void> {
if (this.savedOrDeleted) return;
this.savedOrDeleted = true;
const nameChanged = this.pendingName !== this.originalName;
this.entry.name = this.pendingName;
this.entry.color = this.pendingColor;
this.entry.width = this.pendingWidth;
this.entry.lineStyle = this.pendingLineStyle;
this.entry.routing = this.pendingRouting;
// If name changed, update all pathChain typeName refs
if (nameChanged) {
for (const region of this.plugin.settings.regions) {
for (const chain of region.pathChains) {
if (chain.typeName === this.originalName) chain.typeName = this.pendingName;
}
}
}
await this.plugin.saveSettings();
this.onSave();
}
private async doDelete(): Promise<void> {
if (this.savedOrDeleted) return;
this.savedOrDeleted = true;
// Remove from pathTypes
const idx = this.plugin.settings.pathTypes.indexOf(this.entry);
if (idx !== -1) this.plugin.settings.pathTypes.splice(idx, 1);
// Remove all matching chains
for (const region of this.plugin.settings.regions) {
region.pathChains = region.pathChains.filter(c => c.typeName !== this.originalName);
}
await this.plugin.saveSettings();
this.onDelete();
}
}
import { App, Setting } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import type { PathType, PathLineStyle, PathRouting } from "../types";
import { buildPathPreviewSvg } from "./PathPickerModal";
export class PathTypeEditorModal extends HexmakerModal {
private pendingName: string;
private pendingColor: string;
private pendingWidth: number;
private pendingLineStyle: PathLineStyle;
private pendingRouting: PathRouting;
private readonly originalName: string;
private savedOrDeleted = false;
constructor(
app: App,
private plugin: HexmakerPlugin,
private entry: PathType,
private onSave: () => void,
private onDelete: () => void,
) {
super(app);
this.originalName = entry.name;
this.pendingName = entry.name;
this.pendingColor = entry.color;
this.pendingWidth = entry.width;
this.pendingLineStyle = entry.lineStyle;
this.pendingRouting = entry.routing;
}
onOpen(): void {
this.makeDraggable();
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
contentEl.createEl("h2", { text: "Edit path type" });
// Live preview div
const previewDiv = contentEl.createDiv({ cls: "duckmage-path-preview duckmage-path-editor-preview" });
const redrawPreview = () => {
previewDiv.empty();
previewDiv.appendChild(buildPathPreviewSvg({
name: this.pendingName,
color: this.pendingColor,
width: this.pendingWidth,
lineStyle: this.pendingLineStyle,
routing: this.pendingRouting,
}));
};
redrawPreview();
new Setting(contentEl)
.setName("Name")
.addText(text =>
text
.setValue(this.pendingName)
.onChange(value => {
this.pendingName = value.trim() || this.pendingName;
}),
);
new Setting(contentEl)
.setName("Color")
.addColorPicker(color =>
color
.setValue(this.pendingColor)
.onChange(value => {
this.pendingColor = value;
redrawPreview();
}),
);
new Setting(contentEl)
.setName("Width")
.addSlider(slider =>
slider
.setLimits(1, 10, 1)
.setValue(this.pendingWidth)
.setDynamicTooltip()
.onChange(value => {
this.pendingWidth = value;
redrawPreview();
}),
);
new Setting(contentEl)
.setName("Line style")
.addDropdown(dd =>
dd
.addOption("solid", "Solid")
.addOption("dashed", "Dashed")
.addOption("dotted", "Dotted")
.setValue(this.pendingLineStyle)
.onChange(value => {
this.pendingLineStyle = value as PathLineStyle;
redrawPreview();
}),
);
new Setting(contentEl)
.setName("Routing")
.addDropdown(dd =>
dd
.addOption("through", "Through hex centers")
.addOption("meander", "Meander (curves via edge midpoints)")
.addOption("edge", "Along hex edges (tight boundary)")
.setValue(this.pendingRouting)
.onChange(value => {
this.pendingRouting = value as PathRouting;
redrawPreview();
}),
);
// Save button
const btnRow = contentEl.createDiv({ cls: "duckmage-terrain-editor-actions" });
btnRow.createEl("button", { text: "Save", cls: "mod-cta" }).addEventListener("click", () => {
void (async () => {
await this.doSave();
this.close();
})();
});
// Delete button with confirm
const deleteBtn = btnRow.createEl("button", { text: "Delete", cls: "mod-warning" });
let confirmDiv: HTMLElement | null = null;
deleteBtn.addEventListener("click", () => {
if (confirmDiv) { confirmDiv.remove(); confirmDiv = null; return; }
const chainCount = this.plugin.settings.regions.reduce(
(sum, r) => sum + r.pathChains.filter(c => c.typeName === this.originalName).length,
0,
);
confirmDiv = contentEl.createDiv({ cls: "duckmage-terrain-editor-confirm" });
if (chainCount > 0) {
confirmDiv.createEl("p", {
text: `This type is used in ${chainCount} chain(s). Deleting it will remove those chains. Continue?`,
cls: "duckmage-terrain-editor-confirm-msg",
});
} else {
confirmDiv.createEl("p", { text: "Delete this path type?", cls: "duckmage-terrain-editor-confirm-msg" });
}
confirmDiv.createEl("button", { text: "Yes, delete", cls: "mod-warning" }).addEventListener("click", () => {
void (async () => {
await this.doDelete();
this.close();
})();
});
confirmDiv.createEl("button", { text: "Cancel" }).addEventListener("click", () => {
confirmDiv?.remove();
confirmDiv = null;
});
});
}
onClose(): void {
if (!this.savedOrDeleted) {
void this.doSave();
}
this.contentEl.empty();
}
private async doSave(): Promise<void> {
if (this.savedOrDeleted) return;
this.savedOrDeleted = true;
const nameChanged = this.pendingName !== this.originalName;
this.entry.name = this.pendingName;
this.entry.color = this.pendingColor;
this.entry.width = this.pendingWidth;
this.entry.lineStyle = this.pendingLineStyle;
this.entry.routing = this.pendingRouting;
// If name changed, update all pathChain typeName refs
if (nameChanged) {
for (const region of this.plugin.settings.regions) {
for (const chain of region.pathChains) {
if (chain.typeName === this.originalName) chain.typeName = this.pendingName;
}
}
}
await this.plugin.saveSettings();
this.onSave();
}
private async doDelete(): Promise<void> {
if (this.savedOrDeleted) return;
this.savedOrDeleted = true;
// Remove from pathTypes
const idx = this.plugin.settings.pathTypes.indexOf(this.entry);
if (idx !== -1) this.plugin.settings.pathTypes.splice(idx, 1);
// Remove all matching chains
for (const region of this.plugin.settings.regions) {
region.pathChains = region.pathChains.filter(c => c.typeName !== this.originalName);
}
await this.plugin.saveSettings();
this.onDelete();
}
}

View file

@ -1,218 +1,218 @@
import { App, Setting, TFile } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import type { TerrainColor } from "../types";
import { normalizeFolder } from "../utils";
export class TerrainEntryEditorModal extends HexmakerModal {
// Pending values — only written to the entry on Save
private pendingName: string;
private pendingColor: string;
private pendingIcon: string | undefined;
private pendingIconColor: string | undefined;
private pendingCategory: string | undefined;
private readonly originalName: string;
// Set to true by any explicit button action so onClose doesn't also autosave
private savedOrDeleted = false;
constructor(
app: App,
private plugin: HexmakerPlugin,
private palette: TerrainColor[],
private entry: TerrainColor,
private onSave: () => void,
private onDelete: () => void,
private isNew = false,
) {
super(app);
this.originalName = entry.name;
this.pendingName = entry.name;
this.pendingColor = entry.color;
this.pendingIcon = entry.icon;
this.pendingIconColor = entry.iconColor;
this.pendingCategory = entry.category;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
contentEl.createEl("h2", { text: "Edit terrain type" });
new Setting(contentEl)
.setName("Name")
.addText(text =>
text
.setValue(this.pendingName)
.onChange(value => { this.pendingName = value.trim() || this.pendingName; }),
);
new Setting(contentEl)
.setName("Color")
.addColorPicker(color =>
color
.setValue(this.pendingColor)
.onChange(value => { this.pendingColor = value; }),
);
new Setting(contentEl)
.setName("Icon")
.addDropdown(dropdown => {
dropdown.addOption("", "— no icon —");
for (const icon of this.plugin.availableIcons) {
const label = icon.replace(/^bw-/, "").replace(/\.png$/, "").replace(/-/g, " ");
dropdown.addOption(icon, label);
}
dropdown.setValue(this.pendingIcon ?? "");
dropdown.onChange(value => { this.pendingIcon = value || undefined; });
});
// Track last picked colour so toggling on restores it rather than resetting to white
let lastIconColorPick = this.pendingIconColor ?? "#ffffff";
let tintToggle: import("obsidian").ToggleComponent | undefined;
new Setting(contentEl)
.setName("Icon tint")
.setDesc("Apply a solid colour to the icon shape (works best with monochrome icons).")
.addToggle(toggle => {
tintToggle = toggle;
toggle
.setValue(!!this.pendingIconColor)
.onChange(enabled => {
this.pendingIconColor = enabled ? lastIconColorPick : undefined;
});
})
.addColorPicker(picker =>
picker
.setValue(lastIconColorPick)
.onChange(value => {
lastIconColorPick = value;
// Auto-enable tint when the user picks a colour
if (this.pendingIconColor === undefined) {
this.pendingIconColor = value;
tintToggle?.setValue(true);
} else {
this.pendingIconColor = value;
}
}),
);
// Collect existing categories from the palette for the datalist
const existingCategories = [...new Set(
this.palette
.map(e => e.category)
.filter((c): c is string => !!c),
)].sort();
let categoryInputEl: HTMLInputElement | undefined;
new Setting(contentEl)
.setName("Category")
.setDesc("Group this terrain with similar types in the filter.")
.addText(text => {
text
.setValue(this.pendingCategory ?? "")
.setPlaceholder("E.g. Sea, forest, mountain…")
.onChange(value => { this.pendingCategory = value.trim() || undefined; });
categoryInputEl = text.inputEl;
});
if (categoryInputEl && existingCategories.length > 0) {
const dl = contentEl.createEl("datalist");
dl.id = "duckmage-terrain-category-dl";
categoryInputEl.setAttribute("list", "duckmage-terrain-category-dl");
for (const cat of existingCategories) {
dl.createEl("option", { value: cat });
}
}
const btnRow = contentEl.createDiv({ cls: "duckmage-tee-buttons" });
const saveBtn = btnRow.createEl("button", { cls: "mod-cta", text: "Save" });
saveBtn.addEventListener("click", () => {
this.savedOrDeleted = true;
const nameChanged = this.pendingName !== this.originalName;
saveBtn.disabled = true;
saveBtn.setText(nameChanged ? "Updating hexes…" : "Saving…");
void this.doSave().then(() => this.close());
});
btnRow.createEl("button", { text: "Cancel" }).addEventListener("click", () => {
this.savedOrDeleted = true;
this.close();
});
const deleteBtn = btnRow.createEl("button", { cls: "duckmage-btn-danger", text: "Delete" });
deleteBtn.addEventListener("click", () => {
void (async () => {
this.savedOrDeleted = true;
const idx = this.palette.indexOf(this.entry);
if (idx >= 0) this.palette.splice(idx, 1);
await this.plugin.saveSettings();
this.onDelete();
this.close();
})();
});
this.makeDraggable();
}
onClose(): void {
if (!this.savedOrDeleted) {
void this.doSave();
}
this.contentEl.empty();
}
private async doSave(): Promise<void> {
const nameChanged = this.pendingName !== this.originalName;
this.entry.color = this.pendingColor;
this.entry.icon = this.pendingIcon;
this.entry.iconColor = this.pendingIconColor;
this.entry.category = this.pendingCategory;
if (this.isNew) {
// Brand-new entry — no hex can have this terrain yet and no table files exist
// to rename. Just commit the name and create fresh table files.
this.entry.name = this.pendingName;
await this.plugin.saveSettings();
await this.plugin.ensureTerrainTables();
this.plugin.refreshHexMap();
} else if (nameChanged) {
const oldName = this.originalName;
const newName = this.pendingName;
const overrides = await this.plugin.renameTerrainInHexes(oldName, newName);
await this.renameTerrainTables(oldName, newName);
this.entry.name = newName;
await this.plugin.saveSettings();
this.plugin.refreshHexMapWithOverrides(overrides);
this.plugin.refreshHexTableTerrainRename(oldName, newName);
} else {
await this.plugin.saveSettings();
this.plugin.refreshHexMap();
}
this.onSave();
}
private getTerrainTablePath(name: string, type: "description" | "encounters"): string {
const folder = normalizeFolder(this.plugin.settings.tablesFolder);
const sub = folder ? `${folder}/terrain` : "terrain";
return `${sub}/${type}/${name}.md`;
}
private async renameTerrainTables(oldName: string, newName: string): Promise<void> {
for (const type of ["description", "encounters"] as const) {
const oldPath = this.getTerrainTablePath(oldName, type);
const newPath = this.getTerrainTablePath(newName, type);
// Don't overwrite if the new terrain already has its own tables
if (this.app.vault.getAbstractFileByPath(newPath)) continue;
const oldFile = this.app.vault.getAbstractFileByPath(oldPath);
if (!(oldFile instanceof TFile)) continue;
try {
let content = await this.app.vault.read(oldFile);
// Update the terrain frontmatter property to the new name
content = content.replace(/^terrain:[ \t].+$/m, `terrain: ${newName}`);
// Strip the old roller link so ensureRollerLink can add one with the correct path
content = content.replace(/^.*obsidian:\/\/duckmage-roll.*$\n?/m, "");
await this.app.vault.create(newPath, content);
await this.plugin.ensureRollerLink(newPath);
} catch { /* ignore */ }
}
}
}
import { App, Setting, TFile } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import type { TerrainColor } from "../types";
import { normalizeFolder } from "../utils";
export class TerrainEntryEditorModal extends HexmakerModal {
// Pending values — only written to the entry on Save
private pendingName: string;
private pendingColor: string;
private pendingIcon: string | undefined;
private pendingIconColor: string | undefined;
private pendingCategory: string | undefined;
private readonly originalName: string;
// Set to true by any explicit button action so onClose doesn't also autosave
private savedOrDeleted = false;
constructor(
app: App,
private plugin: HexmakerPlugin,
private palette: TerrainColor[],
private entry: TerrainColor,
private onSave: () => void,
private onDelete: () => void,
private isNew = false,
) {
super(app);
this.originalName = entry.name;
this.pendingName = entry.name;
this.pendingColor = entry.color;
this.pendingIcon = entry.icon;
this.pendingIconColor = entry.iconColor;
this.pendingCategory = entry.category;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
contentEl.createEl("h2", { text: "Edit terrain type" });
new Setting(contentEl)
.setName("Name")
.addText(text =>
text
.setValue(this.pendingName)
.onChange(value => { this.pendingName = value.trim() || this.pendingName; }),
);
new Setting(contentEl)
.setName("Color")
.addColorPicker(color =>
color
.setValue(this.pendingColor)
.onChange(value => { this.pendingColor = value; }),
);
new Setting(contentEl)
.setName("Icon")
.addDropdown(dropdown => {
dropdown.addOption("", "— no icon —");
for (const icon of this.plugin.availableIcons) {
const label = icon.replace(/^bw-/, "").replace(/\.png$/, "").replace(/-/g, " ");
dropdown.addOption(icon, label);
}
dropdown.setValue(this.pendingIcon ?? "");
dropdown.onChange(value => { this.pendingIcon = value || undefined; });
});
// Track last picked colour so toggling on restores it rather than resetting to white
let lastIconColorPick = this.pendingIconColor ?? "#ffffff";
let tintToggle: import("obsidian").ToggleComponent | undefined;
new Setting(contentEl)
.setName("Icon tint")
.setDesc("Apply a solid colour to the icon shape (works best with monochrome icons).")
.addToggle(toggle => {
tintToggle = toggle;
toggle
.setValue(!!this.pendingIconColor)
.onChange(enabled => {
this.pendingIconColor = enabled ? lastIconColorPick : undefined;
});
})
.addColorPicker(picker =>
picker
.setValue(lastIconColorPick)
.onChange(value => {
lastIconColorPick = value;
// Auto-enable tint when the user picks a colour
if (this.pendingIconColor === undefined) {
this.pendingIconColor = value;
tintToggle?.setValue(true);
} else {
this.pendingIconColor = value;
}
}),
);
// Collect existing categories from the palette for the datalist
const existingCategories = [...new Set(
this.palette
.map(e => e.category)
.filter((c): c is string => !!c),
)].sort();
let categoryInputEl: HTMLInputElement | undefined;
new Setting(contentEl)
.setName("Category")
.setDesc("Group this terrain with similar types in the filter.")
.addText(text => {
text
.setValue(this.pendingCategory ?? "")
.setPlaceholder("E.g. Sea, forest, mountain…")
.onChange(value => { this.pendingCategory = value.trim() || undefined; });
categoryInputEl = text.inputEl;
});
if (categoryInputEl && existingCategories.length > 0) {
const dl = contentEl.createEl("datalist");
dl.id = "duckmage-terrain-category-dl";
categoryInputEl.setAttribute("list", "duckmage-terrain-category-dl");
for (const cat of existingCategories) {
dl.createEl("option", { value: cat });
}
}
const btnRow = contentEl.createDiv({ cls: "duckmage-tee-buttons" });
const saveBtn = btnRow.createEl("button", { cls: "mod-cta", text: "Save" });
saveBtn.addEventListener("click", () => {
this.savedOrDeleted = true;
const nameChanged = this.pendingName !== this.originalName;
saveBtn.disabled = true;
saveBtn.setText(nameChanged ? "Updating hexes…" : "Saving…");
void this.doSave().then(() => this.close());
});
btnRow.createEl("button", { text: "Cancel" }).addEventListener("click", () => {
this.savedOrDeleted = true;
this.close();
});
const deleteBtn = btnRow.createEl("button", { cls: "duckmage-btn-danger", text: "Delete" });
deleteBtn.addEventListener("click", () => {
void (async () => {
this.savedOrDeleted = true;
const idx = this.palette.indexOf(this.entry);
if (idx >= 0) this.palette.splice(idx, 1);
await this.plugin.saveSettings();
this.onDelete();
this.close();
})();
});
this.makeDraggable();
}
onClose(): void {
if (!this.savedOrDeleted) {
void this.doSave();
}
this.contentEl.empty();
}
private async doSave(): Promise<void> {
const nameChanged = this.pendingName !== this.originalName;
this.entry.color = this.pendingColor;
this.entry.icon = this.pendingIcon;
this.entry.iconColor = this.pendingIconColor;
this.entry.category = this.pendingCategory;
if (this.isNew) {
// Brand-new entry — no hex can have this terrain yet and no table files exist
// to rename. Just commit the name and create fresh table files.
this.entry.name = this.pendingName;
await this.plugin.saveSettings();
await this.plugin.ensureTerrainTables();
this.plugin.refreshHexMap();
} else if (nameChanged) {
const oldName = this.originalName;
const newName = this.pendingName;
const overrides = await this.plugin.renameTerrainInHexes(oldName, newName);
await this.renameTerrainTables(oldName, newName);
this.entry.name = newName;
await this.plugin.saveSettings();
this.plugin.refreshHexMapWithOverrides(overrides);
this.plugin.refreshHexTableTerrainRename(oldName, newName);
} else {
await this.plugin.saveSettings();
this.plugin.refreshHexMap();
}
this.onSave();
}
private getTerrainTablePath(name: string, type: "description" | "encounters"): string {
const folder = normalizeFolder(this.plugin.settings.tablesFolder);
const sub = folder ? `${folder}/terrain` : "terrain";
return `${sub}/${type}/${name}.md`;
}
private async renameTerrainTables(oldName: string, newName: string): Promise<void> {
for (const type of ["description", "encounters"] as const) {
const oldPath = this.getTerrainTablePath(oldName, type);
const newPath = this.getTerrainTablePath(newName, type);
// Don't overwrite if the new terrain already has its own tables
if (this.app.vault.getAbstractFileByPath(newPath)) continue;
const oldFile = this.app.vault.getAbstractFileByPath(oldPath);
if (!(oldFile instanceof TFile)) continue;
try {
let content = await this.app.vault.read(oldFile);
// Update the terrain frontmatter property to the new name
content = content.replace(/^terrain:[ \t].+$/m, `terrain: ${newName}`);
// Strip the old roller link so ensureRollerLink can add one with the correct path
content = content.replace(/^.*obsidian:\/\/duckmage-roll.*$\n?/m, "");
await this.app.vault.create(newPath, content);
await this.plugin.ensureRollerLink(newPath);
} catch { /* ignore */ }
}
}
}

View file

@ -1,339 +1,339 @@
import { App } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import type { TerrainColor } from "../types";
import { getIconUrl, createIconEl } from "../utils";
import { TerrainEntryEditorModal } from "./TerrainEntryEditorModal";
export class TerrainPickerModal extends HexmakerModal {
private editMode = false;
private editChanged = false;
private selectionMade = false;
private currentBrushSize: 1 | 3 | 7;
constructor(
app: App,
private plugin: HexmakerPlugin,
private palette: TerrainColor[],
private onSelect: (terrainName: string | null) => void,
private onPickMode?: () => void,
private onDismiss?: () => void,
brushSize: 1 | 3 | 7 = 1,
private onBrushSizeChange?: (size: 1 | 3 | 7) => void,
) {
super(app);
this.currentBrushSize = brushSize;
}
onOpen(): void {
this.makeDraggable();
this.render();
}
onClose(): void {
if (this.editChanged) {
this.plugin.refreshHexMap();
}
if (!this.selectionMade) {
this.onDismiss?.();
}
this.contentEl.empty();
}
private render(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
const header = contentEl.createDiv({ cls: "duckmage-tpe-header" });
header.createEl("h2", {
text: this.editMode ? "Edit terrain palette" : "Paint terrain",
});
const toggleBtn = header.createEl("button", {
cls: "duckmage-tpe-edit-btn",
text: this.editMode ? "← Done" : "✏ Edit",
});
toggleBtn.addEventListener("click", () => {
this.editMode = !this.editMode;
this.render();
});
if (this.editMode) {
this.renderEditMode();
} else {
this.renderPickMode();
}
}
private renderPickMode(): void {
const { contentEl } = this;
// Brush size selector
const brushRow = contentEl.createDiv({ cls: "duckmage-tpe-brush-row" });
brushRow.createSpan({ text: "Brush:", cls: "duckmage-tpe-brush-label" });
for (const size of [1, 3, 7] as const) {
const label = size === 1 ? "1×" : size === 3 ? "3×" : "7×";
const btn = brushRow.createEl("button", {
text: label,
cls: "duckmage-tpe-brush-btn",
});
btn.toggleClass("is-active", this.currentBrushSize === size);
btn.addEventListener("click", () => {
this.currentBrushSize = size;
this.onBrushSizeChange?.(size);
brushRow
.querySelectorAll<HTMLElement>(".duckmage-tpe-brush-btn")
.forEach((b) => b.toggleClass("is-active", b.textContent === label));
});
}
const section = contentEl.createDiv({ cls: "duckmage-editor-section" });
const grid = section.createDiv({
cls: "duckmage-terrain-picker duckmage-terrain-picker-full",
});
const eyeBtn = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-eyedropper",
});
eyeBtn
.createDiv({
cls: "duckmage-terrain-preview duckmage-terrain-preview-eyedropper",
})
.setText("⌖");
eyeBtn.createSpan({ text: "Pick", cls: "duckmage-terrain-option-name" });
eyeBtn.addEventListener("click", () => {
this.selectionMade = true;
this.onPickMode?.();
this.close();
});
const clearBtn = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-clear",
});
clearBtn.createDiv({
cls: "duckmage-terrain-preview duckmage-terrain-preview-clear",
});
clearBtn.createSpan({ text: "Clear", cls: "duckmage-terrain-option-name" });
clearBtn.addEventListener("click", () => {
this.selectionMade = true;
this.onSelect(null);
this.close();
});
for (const entry of this.palette) {
const btn = grid.createDiv({ cls: "duckmage-terrain-option" });
const preview = btn.createDiv({ cls: "duckmage-terrain-preview" });
preview.setCssProps({ 'background-color': entry.color });
if (entry.icon) {
createIconEl(preview, getIconUrl(this.plugin, entry.icon), entry.name, entry.iconColor, "duckmage-terrain-preview-icon");
}
btn.createSpan({ text: entry.name, cls: "duckmage-terrain-option-name" });
btn.addEventListener("click", () => {
this.selectionMade = true;
this.onSelect(entry.name);
this.close();
});
}
}
private renderEditMode(): void {
const { contentEl } = this;
const palette = this.palette;
const grid = contentEl.createDiv({
cls: "duckmage-terrain-picker duckmage-terrain-picker-full",
});
let dragSrcIndex = -1;
const renderTiles = () => {
grid.empty();
for (let i = 0; i < palette.length; i++) {
const entry = palette[i];
const tile = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-editable",
});
tile.draggable = true;
// Grip handle — top-left
tile.createSpan({ cls: "duckmage-terrain-edit-grip", text: "⠿" });
// Edit pencil — decorative indicator that the tile is clickable
tile.createSpan({ cls: "duckmage-terrain-edit-pencil", text: "✏" });
// Click anywhere on the tile to open the editor
tile.addEventListener("click", () => {
new TerrainEntryEditorModal(
this.app,
this.plugin,
palette,
entry,
() => { this.editChanged = true; renderTiles(); },
() => { this.editChanged = true; renderTiles(); },
).open();
});
// Standard colored preview + icon (same as pick mode)
const preview = tile.createDiv({ cls: "duckmage-terrain-preview" });
preview.setCssProps({ 'background-color': entry.color });
if (entry.icon) {
createIconEl(preview, getIconUrl(this.plugin, entry.icon), entry.name, entry.iconColor, "duckmage-terrain-preview-icon");
}
tile.createSpan({
text: entry.name,
cls: "duckmage-terrain-option-name",
});
// Drag-to-reorder
tile.addEventListener("dragstart", (e: DragEvent) => {
dragSrcIndex = i;
tile.addClass("duckmage-palette-dragging");
e.dataTransfer?.setDragImage(tile, 0, 0);
});
tile.addEventListener("dragend", () => {
tile.removeClass("duckmage-palette-dragging");
grid
.querySelectorAll(".duckmage-palette-drop-target")
.forEach((el) =>
el.classList.remove("duckmage-palette-drop-target"),
);
});
tile.addEventListener("dragover", (e: DragEvent) => {
e.preventDefault();
grid
.querySelectorAll(".duckmage-palette-drop-target")
.forEach((el) =>
el.classList.remove("duckmage-palette-drop-target"),
);
tile.addClass("duckmage-palette-drop-target");
});
tile.addEventListener("drop", (e: DragEvent) => {
e.preventDefault();
if (dragSrcIndex === -1 || dragSrcIndex === i) return;
const [moved] = palette.splice(dragSrcIndex, 1);
palette.splice(i, 0, moved);
dragSrcIndex = -1;
this.editChanged = true;
void this.plugin.saveSettings().then(() => renderTiles());
});
}
// "+" add tile at the end
const addTile = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-add",
});
addTile
.createDiv({
cls: "duckmage-terrain-preview duckmage-terrain-preview-add",
})
.setText("+");
addTile.createSpan({ text: "Add", cls: "duckmage-terrain-option-name" });
addTile.addEventListener("click", () => {
new TerrainTemplatePickerModal(
this.app,
this.plugin,
palette,
(template) => {
void (async () => {
const newEntry: TerrainColor = template
? { ...template }
: { name: "New", color: "#888888" };
palette.push(newEntry);
this.editChanged = true;
await this.plugin.saveSettings();
renderTiles();
// Open editor with isNew=true so doSave creates tables under the final name
// rather than scanning hexes for a rename.
new TerrainEntryEditorModal(
this.app,
this.plugin,
palette,
newEntry,
() => { this.editChanged = true; renderTiles(); },
() => { this.editChanged = true; renderTiles(); },
true,
).open();
})();
},
).open();
});
};
renderTiles();
// Debug: one-shot button to fix all hex encounter-table links on the current map
const footer = contentEl.createDiv({ cls: "duckmage-tpe-edit-footer" });
const refreshBtn = footer.createEl("button", {
cls: "duckmage-tpe-refresh-btn",
text: "Refresh all encounter table links",
title:
"Re-links every hex's encounters table section to match its current terrain",
});
refreshBtn.addEventListener("click", () => {
void (async () => {
refreshBtn.disabled = true;
refreshBtn.setText("Refreshing…");
await this.plugin.refreshAllTerrainEncounterLinks();
refreshBtn.setText("Done");
})();
});
}
}
/** Shown when the user clicks "+" — lets them pick a terrain from another palette as a template. */
class TerrainTemplatePickerModal extends HexmakerModal {
constructor(
app: App,
private plugin: HexmakerPlugin,
private currentPalette: TerrainColor[],
private onPick: (template: TerrainColor | null) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
contentEl.createEl("h3", { text: "Add terrain" });
const blankBtn = contentEl.createEl("button", {
cls: "duckmage-draw-btn mod-cta",
text: "New blank",
});
blankBtn.addEventListener("click", () => { this.onPick(null); this.close(); });
// Collect terrains from other palettes not already in this one
const currentNames = new Set(this.currentPalette.map(t => t.name));
const byPalette: { name: string; terrains: TerrainColor[] }[] = [];
for (const pal of this.plugin.settings.terrainPalettes) {
if (pal.terrains === this.currentPalette) continue;
const available = pal.terrains.filter(t => !currentNames.has(t.name));
if (available.length > 0) byPalette.push({ name: pal.name, terrains: available });
}
if (byPalette.length > 0) {
contentEl.createEl("p", {
text: "Or copy from an existing palette:",
cls: "setting-item-description",
});
const list = contentEl.createDiv({ cls: "duckmage-terrain-template-list" });
for (const { name: palName, terrains } of byPalette) {
list.createDiv({ cls: "duckmage-terrain-template-group", text: palName });
for (const t of terrains) {
const row = list.createDiv({ cls: "duckmage-terrain-template-row" });
const swatch = row.createSpan({ cls: "duckmage-terrain-template-swatch" });
swatch.setCssProps({ background: t.color });
row.createSpan({ text: t.name });
row.addEventListener("click", () => { this.onPick(t); this.close(); });
}
}
}
this.makeDraggable();
}
onClose(): void {
this.contentEl.empty();
}
}
import { App } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import type { TerrainColor } from "../types";
import { getIconUrl, createIconEl } from "../utils";
import { TerrainEntryEditorModal } from "./TerrainEntryEditorModal";
export class TerrainPickerModal extends HexmakerModal {
private editMode = false;
private editChanged = false;
private selectionMade = false;
private currentBrushSize: 1 | 3 | 7;
constructor(
app: App,
private plugin: HexmakerPlugin,
private palette: TerrainColor[],
private onSelect: (terrainName: string | null) => void,
private onPickMode?: () => void,
private onDismiss?: () => void,
brushSize: 1 | 3 | 7 = 1,
private onBrushSizeChange?: (size: 1 | 3 | 7) => void,
) {
super(app);
this.currentBrushSize = brushSize;
}
onOpen(): void {
this.makeDraggable();
this.render();
}
onClose(): void {
if (this.editChanged) {
this.plugin.refreshHexMap();
}
if (!this.selectionMade) {
this.onDismiss?.();
}
this.contentEl.empty();
}
private render(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
const header = contentEl.createDiv({ cls: "duckmage-tpe-header" });
header.createEl("h2", {
text: this.editMode ? "Edit terrain palette" : "Paint terrain",
});
const toggleBtn = header.createEl("button", {
cls: "duckmage-tpe-edit-btn",
text: this.editMode ? "← Done" : "✏ Edit",
});
toggleBtn.addEventListener("click", () => {
this.editMode = !this.editMode;
this.render();
});
if (this.editMode) {
this.renderEditMode();
} else {
this.renderPickMode();
}
}
private renderPickMode(): void {
const { contentEl } = this;
// Brush size selector
const brushRow = contentEl.createDiv({ cls: "duckmage-tpe-brush-row" });
brushRow.createSpan({ text: "Brush:", cls: "duckmage-tpe-brush-label" });
for (const size of [1, 3, 7] as const) {
const label = size === 1 ? "1×" : size === 3 ? "3×" : "7×";
const btn = brushRow.createEl("button", {
text: label,
cls: "duckmage-tpe-brush-btn",
});
btn.toggleClass("is-active", this.currentBrushSize === size);
btn.addEventListener("click", () => {
this.currentBrushSize = size;
this.onBrushSizeChange?.(size);
brushRow
.querySelectorAll<HTMLElement>(".duckmage-tpe-brush-btn")
.forEach((b) => b.toggleClass("is-active", b.textContent === label));
});
}
const section = contentEl.createDiv({ cls: "duckmage-editor-section" });
const grid = section.createDiv({
cls: "duckmage-terrain-picker duckmage-terrain-picker-full",
});
const eyeBtn = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-eyedropper",
});
eyeBtn
.createDiv({
cls: "duckmage-terrain-preview duckmage-terrain-preview-eyedropper",
})
.setText("⌖");
eyeBtn.createSpan({ text: "Pick", cls: "duckmage-terrain-option-name" });
eyeBtn.addEventListener("click", () => {
this.selectionMade = true;
this.onPickMode?.();
this.close();
});
const clearBtn = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-clear",
});
clearBtn.createDiv({
cls: "duckmage-terrain-preview duckmage-terrain-preview-clear",
});
clearBtn.createSpan({ text: "Clear", cls: "duckmage-terrain-option-name" });
clearBtn.addEventListener("click", () => {
this.selectionMade = true;
this.onSelect(null);
this.close();
});
for (const entry of this.palette) {
const btn = grid.createDiv({ cls: "duckmage-terrain-option" });
const preview = btn.createDiv({ cls: "duckmage-terrain-preview" });
preview.setCssProps({ 'background-color': entry.color });
if (entry.icon) {
createIconEl(preview, getIconUrl(this.plugin, entry.icon), entry.name, entry.iconColor, "duckmage-terrain-preview-icon");
}
btn.createSpan({ text: entry.name, cls: "duckmage-terrain-option-name" });
btn.addEventListener("click", () => {
this.selectionMade = true;
this.onSelect(entry.name);
this.close();
});
}
}
private renderEditMode(): void {
const { contentEl } = this;
const palette = this.palette;
const grid = contentEl.createDiv({
cls: "duckmage-terrain-picker duckmage-terrain-picker-full",
});
let dragSrcIndex = -1;
const renderTiles = () => {
grid.empty();
for (let i = 0; i < palette.length; i++) {
const entry = palette[i];
const tile = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-editable",
});
tile.draggable = true;
// Grip handle — top-left
tile.createSpan({ cls: "duckmage-terrain-edit-grip", text: "⠿" });
// Edit pencil — decorative indicator that the tile is clickable
tile.createSpan({ cls: "duckmage-terrain-edit-pencil", text: "✏" });
// Click anywhere on the tile to open the editor
tile.addEventListener("click", () => {
new TerrainEntryEditorModal(
this.app,
this.plugin,
palette,
entry,
() => { this.editChanged = true; renderTiles(); },
() => { this.editChanged = true; renderTiles(); },
).open();
});
// Standard colored preview + icon (same as pick mode)
const preview = tile.createDiv({ cls: "duckmage-terrain-preview" });
preview.setCssProps({ 'background-color': entry.color });
if (entry.icon) {
createIconEl(preview, getIconUrl(this.plugin, entry.icon), entry.name, entry.iconColor, "duckmage-terrain-preview-icon");
}
tile.createSpan({
text: entry.name,
cls: "duckmage-terrain-option-name",
});
// Drag-to-reorder
tile.addEventListener("dragstart", (e: DragEvent) => {
dragSrcIndex = i;
tile.addClass("duckmage-palette-dragging");
e.dataTransfer?.setDragImage(tile, 0, 0);
});
tile.addEventListener("dragend", () => {
tile.removeClass("duckmage-palette-dragging");
grid
.querySelectorAll(".duckmage-palette-drop-target")
.forEach((el) =>
el.classList.remove("duckmage-palette-drop-target"),
);
});
tile.addEventListener("dragover", (e: DragEvent) => {
e.preventDefault();
grid
.querySelectorAll(".duckmage-palette-drop-target")
.forEach((el) =>
el.classList.remove("duckmage-palette-drop-target"),
);
tile.addClass("duckmage-palette-drop-target");
});
tile.addEventListener("drop", (e: DragEvent) => {
e.preventDefault();
if (dragSrcIndex === -1 || dragSrcIndex === i) return;
const [moved] = palette.splice(dragSrcIndex, 1);
palette.splice(i, 0, moved);
dragSrcIndex = -1;
this.editChanged = true;
void this.plugin.saveSettings().then(() => renderTiles());
});
}
// "+" add tile at the end
const addTile = grid.createDiv({
cls: "duckmage-terrain-option duckmage-terrain-option-add",
});
addTile
.createDiv({
cls: "duckmage-terrain-preview duckmage-terrain-preview-add",
})
.setText("+");
addTile.createSpan({ text: "Add", cls: "duckmage-terrain-option-name" });
addTile.addEventListener("click", () => {
new TerrainTemplatePickerModal(
this.app,
this.plugin,
palette,
(template) => {
void (async () => {
const newEntry: TerrainColor = template
? { ...template }
: { name: "New", color: "#888888" };
palette.push(newEntry);
this.editChanged = true;
await this.plugin.saveSettings();
renderTiles();
// Open editor with isNew=true so doSave creates tables under the final name
// rather than scanning hexes for a rename.
new TerrainEntryEditorModal(
this.app,
this.plugin,
palette,
newEntry,
() => { this.editChanged = true; renderTiles(); },
() => { this.editChanged = true; renderTiles(); },
true,
).open();
})();
},
).open();
});
};
renderTiles();
// Debug: one-shot button to fix all hex encounter-table links on the current map
const footer = contentEl.createDiv({ cls: "duckmage-tpe-edit-footer" });
const refreshBtn = footer.createEl("button", {
cls: "duckmage-tpe-refresh-btn",
text: "Refresh all encounter table links",
title:
"Re-links every hex's encounters table section to match its current terrain",
});
refreshBtn.addEventListener("click", () => {
void (async () => {
refreshBtn.disabled = true;
refreshBtn.setText("Refreshing…");
await this.plugin.refreshAllTerrainEncounterLinks();
refreshBtn.setText("Done");
})();
});
}
}
/** Shown when the user clicks "+" — lets them pick a terrain from another palette as a template. */
class TerrainTemplatePickerModal extends HexmakerModal {
constructor(
app: App,
private plugin: HexmakerPlugin,
private currentPalette: TerrainColor[],
private onPick: (template: TerrainColor | null) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
contentEl.createEl("h3", { text: "Add terrain" });
const blankBtn = contentEl.createEl("button", {
cls: "duckmage-draw-btn mod-cta",
text: "New blank",
});
blankBtn.addEventListener("click", () => { this.onPick(null); this.close(); });
// Collect terrains from other palettes not already in this one
const currentNames = new Set(this.currentPalette.map(t => t.name));
const byPalette: { name: string; terrains: TerrainColor[] }[] = [];
for (const pal of this.plugin.settings.terrainPalettes) {
if (pal.terrains === this.currentPalette) continue;
const available = pal.terrains.filter(t => !currentNames.has(t.name));
if (available.length > 0) byPalette.push({ name: pal.name, terrains: available });
}
if (byPalette.length > 0) {
contentEl.createEl("p", {
text: "Or copy from an existing palette:",
cls: "setting-item-description",
});
const list = contentEl.createDiv({ cls: "duckmage-terrain-template-list" });
for (const { name: palName, terrains } of byPalette) {
list.createDiv({ cls: "duckmage-terrain-template-group", text: palName });
for (const t of terrains) {
const row = list.createDiv({ cls: "duckmage-terrain-template-row" });
const swatch = row.createSpan({ cls: "duckmage-terrain-template-swatch" });
swatch.setCssProps({ background: t.color });
row.createSpan({ text: t.name });
row.addEventListener("click", () => { this.onPick(t); this.close(); });
}
}
}
this.makeDraggable();
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -1,100 +1,100 @@
import { App, TFile } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import { setSectionContent } from "../sections";
import { getTerrainFromFile } from "../frontmatter";
import { normalizeFolder } from "../utils";
import type HexmakerPlugin from "../HexmakerPlugin";
import { RandomTableModal } from "../random-tables/RandomTableModal";
export class HexCellModal extends HexmakerModal {
private textarea: HTMLTextAreaElement | null = null;
constructor(
app: App,
private title: string,
private body: string,
private isLink: boolean,
private filePath?: string,
private sectionKey?: string,
private plugin?: HexmakerPlugin,
private onSave?: (newContent: string) => void,
private beforeSave?: () => Promise<void>,
) {
super(app);
}
onOpen(): void {
this.makeDraggable();
this.titleEl.setText(this.title);
const { contentEl } = this;
contentEl.addClass("duckmage-cell-modal");
if (this.isLink) {
const list = contentEl.createEl("ul", { cls: "duckmage-cell-modal-list" });
for (const item of this.body.split(", ")) {
list.createEl("li", { text: item });
}
} else {
this.textarea = contentEl.createEl("textarea", {
cls: "duckmage-cell-modal-textarea",
});
this.textarea.value = this.body;
const rollTableFile = this.plugin ? this.getRollTableFile() : null;
if (rollTableFile) {
const btnRow = contentEl.createDiv({ cls: "duckmage-cell-modal-btn-row" });
const rollBtn = btnRow.createEl("button", {
text: "🎲 roll on table",
cls: "duckmage-cell-modal-roll-btn",
});
rollBtn.addEventListener("click", () => {
new RandomTableModal(this.app, this.plugin!, (result) => {
if (this.textarea!.value && !this.textarea!.value.endsWith("\n"))
this.textarea!.value += "\n";
this.textarea!.value += result;
}, rollTableFile.path).open();
});
}
}
}
private async doSave(): Promise<void> {
if (!this.textarea || !this.filePath || !this.sectionKey) return;
const newContent = this.textarea.value;
await this.beforeSave?.();
await setSectionContent(this.app, this.filePath, this.sectionKey, newContent);
this.onSave?.(newContent.trim());
}
onClose(): void {
void this.doSave();
this.contentEl.empty();
this.textarea = null;
}
private getRollTableFile(): TFile | null {
if (!this.plugin || !this.filePath || !this.sectionKey) return null;
const tablesFolder = normalizeFolder(this.plugin.settings.tablesFolder ?? "");
let tablePath: string;
if (this.sectionKey === "description") {
const terrain = getTerrainFromFile(this.app, this.filePath);
if (!terrain) return null;
tablePath = tablesFolder
? `${tablesFolder}/terrain/description/${terrain}.md`
: `terrain/description/${terrain}.md`;
} else if (
this.sectionKey === "landmark" ||
this.sectionKey === "hidden" ||
this.sectionKey === "secret"
) {
tablePath = tablesFolder
? `${tablesFolder}/${this.sectionKey}.md`
: `${this.sectionKey}.md`;
} else {
return null;
}
const file = this.app.vault.getAbstractFileByPath(tablePath);
return file instanceof TFile ? file : null;
}
}
import { App, TFile } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import { setSectionContent } from "../sections";
import { getTerrainFromFile } from "../frontmatter";
import { normalizeFolder } from "../utils";
import type HexmakerPlugin from "../HexmakerPlugin";
import { RandomTableModal } from "../random-tables/RandomTableModal";
export class HexCellModal extends HexmakerModal {
private textarea: HTMLTextAreaElement | null = null;
constructor(
app: App,
private title: string,
private body: string,
private isLink: boolean,
private filePath?: string,
private sectionKey?: string,
private plugin?: HexmakerPlugin,
private onSave?: (newContent: string) => void,
private beforeSave?: () => Promise<void>,
) {
super(app);
}
onOpen(): void {
this.makeDraggable();
this.titleEl.setText(this.title);
const { contentEl } = this;
contentEl.addClass("duckmage-cell-modal");
if (this.isLink) {
const list = contentEl.createEl("ul", { cls: "duckmage-cell-modal-list" });
for (const item of this.body.split(", ")) {
list.createEl("li", { text: item });
}
} else {
this.textarea = contentEl.createEl("textarea", {
cls: "duckmage-cell-modal-textarea",
});
this.textarea.value = this.body;
const rollTableFile = this.plugin ? this.getRollTableFile() : null;
if (rollTableFile) {
const btnRow = contentEl.createDiv({ cls: "duckmage-cell-modal-btn-row" });
const rollBtn = btnRow.createEl("button", {
text: "🎲 roll on table",
cls: "duckmage-cell-modal-roll-btn",
});
rollBtn.addEventListener("click", () => {
new RandomTableModal(this.app, this.plugin!, (result) => {
if (this.textarea!.value && !this.textarea!.value.endsWith("\n"))
this.textarea!.value += "\n";
this.textarea!.value += result;
}, rollTableFile.path).open();
});
}
}
}
private async doSave(): Promise<void> {
if (!this.textarea || !this.filePath || !this.sectionKey) return;
const newContent = this.textarea.value;
await this.beforeSave?.();
await setSectionContent(this.app, this.filePath, this.sectionKey, newContent);
this.onSave?.(newContent.trim());
}
onClose(): void {
void this.doSave();
this.contentEl.empty();
this.textarea = null;
}
private getRollTableFile(): TFile | null {
if (!this.plugin || !this.filePath || !this.sectionKey) return null;
const tablesFolder = normalizeFolder(this.plugin.settings.tablesFolder ?? "");
let tablePath: string;
if (this.sectionKey === "description") {
const terrain = getTerrainFromFile(this.app, this.filePath);
if (!terrain) return null;
tablePath = tablesFolder
? `${tablesFolder}/terrain/description/${terrain}.md`
: `terrain/description/${terrain}.md`;
} else if (
this.sectionKey === "landmark" ||
this.sectionKey === "hidden" ||
this.sectionKey === "secret"
) {
tablePath = tablesFolder
? `${tablesFolder}/${this.sectionKey}.md`
: `${this.sectionKey}.md`;
} else {
return null;
}
const file = this.app.vault.getAbstractFileByPath(tablePath);
return file instanceof TFile ? file : null;
}
}

View file

@ -1,75 +1,75 @@
import { App } from "obsidian";
import type HexmakerPlugin from "../HexmakerPlugin";
import { HexmakerModal } from "../HexmakerModal";
import { getIconUrl, normalizeFolder, createIconEl } from "../utils";
import { setTerrainInFile } from "../frontmatter";
export class HexTerrainPickerModal extends HexmakerModal {
constructor(
app: App,
private plugin: HexmakerPlugin,
private palette: import("../types").TerrainColor[],
private hexPath: string,
private currentTerrain: string | null,
private onPicked: () => void,
) {
super(app);
}
onOpen(): void {
this.makeDraggable();
this.titleEl.setText("Select terrain");
const { contentEl } = this;
contentEl.addClass("duckmage-terrain-picker-modal");
const grid = contentEl.createDiv({
cls: "duckmage-terrain-picker duckmage-terrain-picker-full",
});
for (const entry of this.palette) {
const btn = grid.createDiv({
cls: `duckmage-terrain-option${entry.name === this.currentTerrain ? " is-selected" : ""}`,
});
const preview = btn.createDiv({ cls: "duckmage-terrain-preview" });
preview.style.backgroundColor = entry.color;
if (entry.icon) {
createIconEl(preview, getIconUrl(this.plugin, entry.icon), entry.name, entry.iconColor, "duckmage-terrain-preview-icon");
}
btn.createSpan({ text: entry.name, cls: "duckmage-terrain-option-name" });
btn.addEventListener("click", () => {
void (async () => {
if (!this.app.vault.getAbstractFileByPath(this.hexPath)) {
const basename = this.hexPath.replace(/\.md$/, "").split("/").pop()!;
const [hx, hy] = basename.split("_").map(Number);
const hexFolder = normalizeFolder(this.plugin.settings.hexFolder);
const relative = hexFolder
? this.hexPath.slice(hexFolder.length + 1)
: this.hexPath;
const regionName = relative.split("/")[0];
await this.plugin.createHexNote(hx, hy, regionName);
}
await setTerrainInFile(this.app, this.hexPath, entry.name);
this.onPicked();
this.close();
})();
});
}
if (this.currentTerrain) {
const clearBtn = contentEl.createEl("button", {
text: "Clear terrain",
cls: "duckmage-clear-btn mod-warning",
});
clearBtn.addEventListener("click", () => {
void (async () => {
await setTerrainInFile(this.app, this.hexPath, null);
this.onPicked();
this.close();
})();
});
}
}
onClose(): void {
this.contentEl.empty();
}
}
import { App } from "obsidian";
import type HexmakerPlugin from "../HexmakerPlugin";
import { HexmakerModal } from "../HexmakerModal";
import { getIconUrl, normalizeFolder, createIconEl } from "../utils";
import { setTerrainInFile } from "../frontmatter";
export class HexTerrainPickerModal extends HexmakerModal {
constructor(
app: App,
private plugin: HexmakerPlugin,
private palette: import("../types").TerrainColor[],
private hexPath: string,
private currentTerrain: string | null,
private onPicked: () => void,
) {
super(app);
}
onOpen(): void {
this.makeDraggable();
this.titleEl.setText("Select terrain");
const { contentEl } = this;
contentEl.addClass("duckmage-terrain-picker-modal");
const grid = contentEl.createDiv({
cls: "duckmage-terrain-picker duckmage-terrain-picker-full",
});
for (const entry of this.palette) {
const btn = grid.createDiv({
cls: `duckmage-terrain-option${entry.name === this.currentTerrain ? " is-selected" : ""}`,
});
const preview = btn.createDiv({ cls: "duckmage-terrain-preview" });
preview.style.backgroundColor = entry.color;
if (entry.icon) {
createIconEl(preview, getIconUrl(this.plugin, entry.icon), entry.name, entry.iconColor, "duckmage-terrain-preview-icon");
}
btn.createSpan({ text: entry.name, cls: "duckmage-terrain-option-name" });
btn.addEventListener("click", () => {
void (async () => {
if (!this.app.vault.getAbstractFileByPath(this.hexPath)) {
const basename = this.hexPath.replace(/\.md$/, "").split("/").pop()!;
const [hx, hy] = basename.split("_").map(Number);
const hexFolder = normalizeFolder(this.plugin.settings.hexFolder);
const relative = hexFolder
? this.hexPath.slice(hexFolder.length + 1)
: this.hexPath;
const regionName = relative.split("/")[0];
await this.plugin.createHexNote(hx, hy, regionName);
}
await setTerrainInFile(this.app, this.hexPath, entry.name);
this.onPicked();
this.close();
})();
});
}
if (this.currentTerrain) {
const clearBtn = contentEl.createEl("button", {
text: "Clear terrain",
cls: "duckmage-clear-btn mod-warning",
});
clearBtn.addEventListener("click", () => {
void (async () => {
await setTerrainInFile(this.app, this.hexPath, null);
this.onPicked();
this.close();
})();
});
}
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -1,41 +1,41 @@
import { App, TFile } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
export class MultiLinkNavModal extends HexmakerModal {
constructor(
app: App,
private title: string,
private linkTargets: string[],
private sourcePath: string,
) {
super(app);
}
onOpen(): void {
this.makeDraggable();
this.titleEl.setText(this.title);
const { contentEl } = this;
contentEl.addClass("duckmage-link-picker-modal");
const list = contentEl.createEl("ul", { cls: "duckmage-link-picker-list" });
for (const target of this.linkTargets) {
const li = list.createEl("li", {
cls: "duckmage-link-picker-item",
text: target,
});
li.addEventListener("click", () => {
const file = this.app.metadataCache.getFirstLinkpathDest(
target,
this.sourcePath,
);
if (file instanceof TFile) {
void this.app.workspace.getLeaf().openFile(file);
this.close();
}
});
}
}
onClose(): void {
this.contentEl.empty();
}
}
import { App, TFile } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
export class MultiLinkNavModal extends HexmakerModal {
constructor(
app: App,
private title: string,
private linkTargets: string[],
private sourcePath: string,
) {
super(app);
}
onOpen(): void {
this.makeDraggable();
this.titleEl.setText(this.title);
const { contentEl } = this;
contentEl.addClass("duckmage-link-picker-modal");
const list = contentEl.createEl("ul", { cls: "duckmage-link-picker-list" });
for (const target of this.linkTargets) {
const li = list.createEl("li", {
cls: "duckmage-link-picker-item",
text: target,
});
li.addEventListener("click", () => {
const file = this.app.metadataCache.getFirstLinkpathDest(
target,
this.sourcePath,
);
if (file instanceof TFile) {
void this.app.workspace.getLeaf().openFile(file);
this.close();
}
});
}
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -1,160 +1,160 @@
import { App } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type { TerrainColor } from "../types";
export class TerrainFilterModal extends HexmakerModal {
constructor(
app: App,
private palette: TerrainColor[],
private selected: Set<string>,
private excluded: Set<string>,
private onChange: (selected: Set<string>, excluded: Set<string>) => void,
) {
super(app);
}
onOpen(): void {
this.makeDraggable();
this.titleEl.setText("Filter by terrain");
const { contentEl } = this;
contentEl.addClass("duckmage-terrain-filter-modal");
contentEl.createEl("p", {
text: "Left-click to include · right-click to exclude · click a category heading to toggle all",
cls: "duckmage-terrain-filter-hint",
});
const list = contentEl.createDiv({ cls: "duckmage-terrain-filter-list" });
// Map terrain name → row elements, so category headings can bulk-update them
const rowRefs = new Map<string, { lbl: HTMLElement; cb: HTMLInputElement }>();
const applyRowState = (lbl: HTMLElement, cb: HTMLInputElement, name: string) => {
cb.checked = this.selected.has(name);
lbl.toggleClass("duckmage-terrain-filter-excluded", this.excluded.has(name));
};
const addRow = (name: string, label: string, color?: string, indented = false) => {
const lbl = list.createEl("label", {
cls: "duckmage-terrain-filter-row" + (indented ? " duckmage-terrain-filter-row-indented" : ""),
});
const cb = lbl.createEl("input");
cb.type = "checkbox";
applyRowState(lbl, cb, name);
const swatch = lbl.createSpan({ cls: "duckmage-hex-table-swatch" });
if (color) swatch.style.backgroundColor = color;
lbl.createSpan({ text: label });
rowRefs.set(name, { lbl, cb });
cb.addEventListener("change", () => {
if (cb.checked) {
this.selected.add(name);
this.excluded.delete(name);
} else {
this.selected.delete(name);
}
applyRowState(lbl, cb, name);
this.onChange(new Set(this.selected), new Set(this.excluded));
});
lbl.addEventListener("contextmenu", (e) => {
e.preventDefault();
if (this.excluded.has(name)) {
this.excluded.delete(name);
} else {
this.excluded.add(name);
this.selected.delete(name);
}
applyRowState(lbl, cb, name);
this.onChange(new Set(this.selected), new Set(this.excluded));
});
};
const addCategoryHeading = (label: string, names: string[]) => {
const heading = list.createDiv({ cls: "duckmage-terrain-filter-category-heading" });
heading.createSpan({ text: label });
const refreshRows = () => {
for (const name of names) {
const ref = rowRefs.get(name);
if (ref) applyRowState(ref.lbl, ref.cb, name);
}
};
// Left-click: include all (or deselect all if all already included)
heading.addEventListener("click", () => {
const allIncluded = names.every(n => this.selected.has(n));
if (allIncluded) {
names.forEach(n => this.selected.delete(n));
} else {
names.forEach(n => { this.selected.add(n); this.excluded.delete(n); });
}
refreshRows();
this.onChange(new Set(this.selected), new Set(this.excluded));
});
// Right-click: exclude all (or un-exclude all if all already excluded)
heading.addEventListener("contextmenu", (e) => {
e.preventDefault();
const allExcluded = names.every(n => this.excluded.has(n));
if (allExcluded) {
names.forEach(n => this.excluded.delete(n));
} else {
names.forEach(n => { this.excluded.add(n); this.selected.delete(n); });
}
refreshRows();
this.onChange(new Set(this.selected), new Set(this.excluded));
});
};
// "No terrain" is always first and never grouped
addRow("", "No terrain");
// Split palette into ungrouped and category groups
const groups = new Map<string, TerrainColor[]>();
const ungrouped: TerrainColor[] = [];
for (const entry of this.palette) {
if (entry.category) {
if (!groups.has(entry.category)) groups.set(entry.category, []);
groups.get(entry.category)!.push(entry);
} else {
ungrouped.push(entry);
}
}
// Ungrouped terrains — no heading, not indented
for (const entry of ungrouped) {
addRow(entry.name, entry.name, entry.color);
}
// Categorised terrains — heading + indented rows
for (const cat of [...groups.keys()].sort()) {
const entries = groups.get(cat)!;
addCategoryHeading(cat, entries.map(e => e.name));
for (const entry of entries) {
addRow(entry.name, entry.name, entry.color, true);
}
}
const btnRow = contentEl.createDiv({ cls: "duckmage-terrain-filter-btns" });
const clearBtn = btnRow.createEl("button", { text: "Clear all" });
clearBtn.addEventListener("click", () => {
this.selected.clear();
this.excluded.clear();
this.onChange(new Set(this.selected), new Set(this.excluded));
for (const { lbl, cb } of rowRefs.values()) {
cb.checked = false;
lbl.removeClass("duckmage-terrain-filter-excluded");
}
});
btnRow
.createEl("button", { text: "Done", cls: "mod-cta" })
.addEventListener("click", () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
}
import { App } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type { TerrainColor } from "../types";
export class TerrainFilterModal extends HexmakerModal {
constructor(
app: App,
private palette: TerrainColor[],
private selected: Set<string>,
private excluded: Set<string>,
private onChange: (selected: Set<string>, excluded: Set<string>) => void,
) {
super(app);
}
onOpen(): void {
this.makeDraggable();
this.titleEl.setText("Filter by terrain");
const { contentEl } = this;
contentEl.addClass("duckmage-terrain-filter-modal");
contentEl.createEl("p", {
text: "Left-click to include · right-click to exclude · click a category heading to toggle all",
cls: "duckmage-terrain-filter-hint",
});
const list = contentEl.createDiv({ cls: "duckmage-terrain-filter-list" });
// Map terrain name → row elements, so category headings can bulk-update them
const rowRefs = new Map<string, { lbl: HTMLElement; cb: HTMLInputElement }>();
const applyRowState = (lbl: HTMLElement, cb: HTMLInputElement, name: string) => {
cb.checked = this.selected.has(name);
lbl.toggleClass("duckmage-terrain-filter-excluded", this.excluded.has(name));
};
const addRow = (name: string, label: string, color?: string, indented = false) => {
const lbl = list.createEl("label", {
cls: "duckmage-terrain-filter-row" + (indented ? " duckmage-terrain-filter-row-indented" : ""),
});
const cb = lbl.createEl("input");
cb.type = "checkbox";
applyRowState(lbl, cb, name);
const swatch = lbl.createSpan({ cls: "duckmage-hex-table-swatch" });
if (color) swatch.style.backgroundColor = color;
lbl.createSpan({ text: label });
rowRefs.set(name, { lbl, cb });
cb.addEventListener("change", () => {
if (cb.checked) {
this.selected.add(name);
this.excluded.delete(name);
} else {
this.selected.delete(name);
}
applyRowState(lbl, cb, name);
this.onChange(new Set(this.selected), new Set(this.excluded));
});
lbl.addEventListener("contextmenu", (e) => {
e.preventDefault();
if (this.excluded.has(name)) {
this.excluded.delete(name);
} else {
this.excluded.add(name);
this.selected.delete(name);
}
applyRowState(lbl, cb, name);
this.onChange(new Set(this.selected), new Set(this.excluded));
});
};
const addCategoryHeading = (label: string, names: string[]) => {
const heading = list.createDiv({ cls: "duckmage-terrain-filter-category-heading" });
heading.createSpan({ text: label });
const refreshRows = () => {
for (const name of names) {
const ref = rowRefs.get(name);
if (ref) applyRowState(ref.lbl, ref.cb, name);
}
};
// Left-click: include all (or deselect all if all already included)
heading.addEventListener("click", () => {
const allIncluded = names.every(n => this.selected.has(n));
if (allIncluded) {
names.forEach(n => this.selected.delete(n));
} else {
names.forEach(n => { this.selected.add(n); this.excluded.delete(n); });
}
refreshRows();
this.onChange(new Set(this.selected), new Set(this.excluded));
});
// Right-click: exclude all (or un-exclude all if all already excluded)
heading.addEventListener("contextmenu", (e) => {
e.preventDefault();
const allExcluded = names.every(n => this.excluded.has(n));
if (allExcluded) {
names.forEach(n => this.excluded.delete(n));
} else {
names.forEach(n => { this.excluded.add(n); this.selected.delete(n); });
}
refreshRows();
this.onChange(new Set(this.selected), new Set(this.excluded));
});
};
// "No terrain" is always first and never grouped
addRow("", "No terrain");
// Split palette into ungrouped and category groups
const groups = new Map<string, TerrainColor[]>();
const ungrouped: TerrainColor[] = [];
for (const entry of this.palette) {
if (entry.category) {
if (!groups.has(entry.category)) groups.set(entry.category, []);
groups.get(entry.category)!.push(entry);
} else {
ungrouped.push(entry);
}
}
// Ungrouped terrains — no heading, not indented
for (const entry of ungrouped) {
addRow(entry.name, entry.name, entry.color);
}
// Categorised terrains — heading + indented rows
for (const cat of [...groups.keys()].sort()) {
const entries = groups.get(cat)!;
addCategoryHeading(cat, entries.map(e => e.name));
for (const entry of entries) {
addRow(entry.name, entry.name, entry.color, true);
}
}
const btnRow = contentEl.createDiv({ cls: "duckmage-terrain-filter-btns" });
const clearBtn = btnRow.createEl("button", { text: "Clear all" });
clearBtn.addEventListener("click", () => {
this.selected.clear();
this.excluded.clear();
this.onChange(new Set(this.selected), new Set(this.excluded));
for (const { lbl, cb } of rowRefs.values()) {
cb.checked = false;
lbl.removeClass("duckmage-terrain-filter-excluded");
}
});
btnRow
.createEl("button", { text: "Done", cls: "mod-cta" })
.addEventListener("click", () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -310,12 +310,42 @@ export class RandomTableEditorModal extends HexmakerModal {
"duckmage-lf-folders-" + Math.random().toString(36).slice(2);
const folderDatalist = contentEl.createEl("datalist");
folderDatalist.id = folderDatalistId;
const worldFolder = normalizeFolder(this.plugin.settings.worldFolder);
const seenFolders = new Set<string>();
// Add configured settings folders first (excluding hexFolder)
const s = this.plugin.settings;
for (const raw of [
s.townsFolder,
s.dungeonsFolder,
s.questsFolder,
s.featuresFolder,
s.factionsFolder,
s.tablesFolder,
s.workflowsFolder,
]) {
const p = normalizeFolder(raw);
if (p && !seenFolders.has(p)) {
seenFolders.add(p);
folderDatalist.createEl("option", { value: p });
}
}
// Add all vault subfolders under worldFolder or any settings folder
const folderRoots = [
s.worldFolder,
s.townsFolder,
s.dungeonsFolder,
s.questsFolder,
s.featuresFolder,
s.factionsFolder,
s.tablesFolder,
s.workflowsFolder,
].map(normalizeFolder).filter(Boolean);
for (const f of this.app.vault.getAllFolders()) {
const p = normalizeFolder(f.path);
if (!p || p === worldFolder) continue;
if (worldFolder && !p.startsWith(worldFolder + "/")) continue;
if (!p) continue;
const underRoot = folderRoots.some(
(r) => p === r || p.startsWith(r + "/"),
);
if (!underRoot) continue;
if (!seenFolders.has(p)) {
seenFolders.add(p);
folderDatalist.createEl("option", { value: p });
@ -419,7 +449,7 @@ export class RandomTableEditorModal extends HexmakerModal {
updatedFm = this.setFrontmatterString(
updatedFm,
"linkedFolder",
linkedFolder || undefined,
linkedFolder ? `"[[${linkedFolder}]]"` : undefined,
);
if (linkedFolder) {
await this.renameUpdatedEntries(

View file

@ -245,11 +245,57 @@ export class RandomTableView extends ItemView {
text: "+ new",
cls: "duckmage-rt-new-btn",
});
const fromFolderDatalistId =
"duckmage-rt-folders-" + Math.random().toString(36).slice(2);
const fromFolderDatalist = this.tableFooterEl.createEl("datalist");
fromFolderDatalist.id = fromFolderDatalistId;
const seenFromFolders = new Set<string>();
// Add configured settings folders first (excluding hexFolder)
for (const raw of [
this.plugin.settings.townsFolder,
this.plugin.settings.dungeonsFolder,
this.plugin.settings.questsFolder,
this.plugin.settings.featuresFolder,
this.plugin.settings.factionsFolder,
this.plugin.settings.tablesFolder,
this.plugin.settings.workflowsFolder,
]) {
const p = normalizeFolder(raw);
if (p && !seenFromFolders.has(p)) {
seenFromFolders.add(p);
fromFolderDatalist.createEl("option", { value: p });
}
}
// Add all vault subfolders under worldFolder or any settings folder
const ps = this.plugin.settings;
const fromFolderRoots = [
ps.worldFolder,
ps.townsFolder,
ps.dungeonsFolder,
ps.questsFolder,
ps.featuresFolder,
ps.factionsFolder,
ps.tablesFolder,
ps.workflowsFolder,
].map(normalizeFolder).filter(Boolean);
for (const f of this.app.vault.getAllFolders()) {
const p = normalizeFolder(f.path);
if (!p) continue;
const underRoot = fromFolderRoots.some(
(r) => p === r || p.startsWith(r + "/"),
);
if (!underRoot) continue;
if (!seenFromFolders.has(p)) {
seenFromFolders.add(p);
fromFolderDatalist.createEl("option", { value: p });
}
}
const fromFolderInput = this.tableFooterEl.createEl("input", {
type: "text",
cls: "duckmage-rt-from-folder-input",
attr: { placeholder: "Generate from folder link (optional)…" },
});
fromFolderInput.setAttribute("list", fromFolderDatalistId);
fromFolderInput.setCssProps({ "margin-top": "6px" });
const createTable = async () => {
@ -278,7 +324,7 @@ export class RandomTableView extends ItemView {
const entryRows = folderFiles
.map((f) => `| [[${f.basename}]] | 1 |`)
.join("\n");
content = `---\ndice: ${this.plugin.settings.defaultTableDice}\nlinkedFolder: ${srcFolder}\n---\n\n${rollerLink}\n\n| Result | Weight |\n|--------|--------|\n${entryRows || "| | 1 |"}\n`;
content = `---\ndice: ${this.plugin.settings.defaultTableDice}\nlinkedFolder: "[[${srcFolder}]]"\n---\n\n${rollerLink}\n\n| Result | Weight |\n|--------|--------|\n${entryRows || "| | 1 |"}\n`;
} else {
const rollerLink = this.plugin.buildRollerLink(newPath);
content = makeTableTemplate(
@ -572,7 +618,11 @@ export class RandomTableView extends ItemView {
| Frontmatter
| undefined
)?.["linkedFolder"];
if (lf) this.linkedFolderMap.set(normalizeFolder(lf), file.path);
if (lf && typeof lf === "string") {
const wikiLinkMatch = /^\[\[(.+?)\]\]$/.exec(lf);
const lfClean = wikiLinkMatch ? wikiLinkMatch[1].trim() : lf;
this.linkedFolderMap.set(normalizeFolder(lfClean), file.path);
}
}
// Rebuild workflow map (table path → [workflow paths])
@ -1424,6 +1474,20 @@ export class RandomTableView extends ItemView {
void this.app.workspace.getLeaf().openFile(file);
});
if (table.linkedFolder) {
const refreshBtn = header.createEl("a", {
text: "Refresh",
cls: "duckmage-rt-edit-link",
});
refreshBtn.title = `Sync entries from ${table.linkedFolder}`;
refreshBtn.addEventListener("click", () => {
void (async () => {
await this.autoSyncLinkedFolder(file);
await this.renderDetail();
})();
});
}
const dieSelect = header.createEl("select", {
cls: "duckmage-rt-die-select",
});

View file

@ -106,16 +106,6 @@ export class WorkflowWizardModal extends HexmakerModal {
rerollAllBtn.title = "Reroll every slot";
rerollAllBtn.addEventListener("click", () => { void this.rollAll(true); });
const copyResultBtn = header.createEl("button", { text: "Copy result" });
copyResultBtn.addEventListener("click", () => {
void 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();
@ -132,6 +122,16 @@ export class WorkflowWizardModal extends HexmakerModal {
this.resultTextarea.readOnly = true;
this.resultTextarea.value = this.assembleResult();
const copyResultBtn = contentEl.createEl("button", { text: "Copy result" });
copyResultBtn.addEventListener("click", () => {
void navigator.clipboard
.writeText(this.resultTextarea?.value ?? "")
.then(() => {
copyResultBtn.setText("Copied!");
setTimeout(() => copyResultBtn.setText("Copy result"), 1500);
});
});
// ── Save section ──────────────────────────────────────────────────
contentEl.createEl("p", {
text: "Save as note",

View file

@ -21,7 +21,13 @@ export function parseRandomTable(content: string): RandomTable {
const diceMatch = /^dice:\s*(\d+)\s*$/m.exec(fmMatch[1]);
if (diceMatch) dice = parseInt(diceMatch[1], 10);
const lfMatch = /^linkedFolder:\s*(.+)$/m.exec(fmMatch[1]);
if (lfMatch) linkedFolder = lfMatch[1].trim();
if (lfMatch) {
const raw = lfMatch[1].trim();
// Strip outer YAML quotes if present ("[[path]]" → [[path]])
const unquoted = /^"(.*)"$/.exec(raw)?.[1] ?? raw;
const wikiLinkMatch = /^\[\[(.+?)\]\]$/.exec(unquoted);
linkedFolder = wikiLinkMatch ? wikiLinkMatch[1].trim() : unquoted;
}
}
// Extract user description from preamble (text between frontmatter and table, excluding roller link)

View file

@ -1,159 +1,159 @@
import { App, TFile } from "obsidian";
/** Insert a wiki-link under the named ### section, creating the section if absent. */
export async function addLinkToSection(app: App, filePath: string, section: string, linkText: string): Promise<void> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return;
await app.vault.process(file, (content) => {
const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi");
const match = headingRegex.exec(content);
if (!match) {
return content.trimEnd() + `\n\n### ${section}\n\n${linkText}\n`;
}
const afterHeading = match.index + match[0].length;
const nextBoundaryMatch = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading));
const sectionEnd = nextBoundaryMatch ? afterHeading + nextBoundaryMatch.index : content.length;
const sectionContent = content.slice(afterHeading, sectionEnd);
if (sectionContent.includes(linkText)) return content;
const trimmedSection = sectionContent.trimEnd();
const insertAt = afterHeading + trimmedSection.length;
return content.slice(0, insertAt) + "\n\n" + linkText + content.slice(insertAt);
});
}
/** Remove a wiki-link from under the named ### section. Removes the whole line containing it. */
export async function removeLinkFromSection(app: App, filePath: string, section: string, linkTarget: string): Promise<void> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return;
await app.vault.process(file, (content) => {
const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi");
const match = headingRegex.exec(content);
if (!match) return content;
const afterHeading = match.index + match[0].length;
const nextBoundaryMatch = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading));
const sectionEnd = nextBoundaryMatch ? afterHeading + nextBoundaryMatch.index : content.length;
const escapedTarget = linkTarget.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const lineRegex = new RegExp(`\\n[^\\n]*\\[\\[${escapedTarget}(?:\\|[^\\]]+)?\\]\\][^\\n]*`, "g");
const sectionBody = content.slice(afterHeading, sectionEnd);
const newBody = sectionBody.replace(lineRegex, "");
return content.slice(0, afterHeading) + newBody + content.slice(sectionEnd);
});
}
/** Return all wiki-link targets found under a named ### section. */
export async function getLinksInSection(app: App, filePath: string, section: string): Promise<string[]> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return [];
const content = await app.vault.read(file);
const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi");
const match = headingRegex.exec(content);
if (!match) return [];
const afterHeading = match.index + match[0].length;
const nextBoundaryMatch = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading));
const sectionEnd = nextBoundaryMatch ? afterHeading + nextBoundaryMatch.index : content.length;
const sectionContent = content.slice(afterHeading, sectionEnd);
const links: string[] = [];
const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
let m;
while ((m = linkRegex.exec(sectionContent)) !== null) {
links.push(m[1]);
}
return links;
}
/** Return the plain text body of a named ### section (stops at next heading or ---). */
export async function getSectionContent(app: App, filePath: string, section: string): Promise<string> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return "";
const content = await app.vault.read(file);
const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi");
const match = headingRegex.exec(content);
if (!match) return "";
const afterHeading = match.index + match[0].length;
const nextBoundary = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading));
const sectionEnd = nextBoundary ? afterHeading + nextBoundary.index : content.length;
return content.slice(afterHeading, sectionEnd).trim();
}
/** Read a file once and return all text and link section content in a single pass. */
export async function getAllSectionData(
app: App,
filePath: string,
preloadedContent?: string,
): Promise<{ text: Map<string, string>; links: Map<string, string[]> }> {
const text = new Map<string, string>();
const links = new Map<string, string[]>();
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return { text, links };
const content = preloadedContent ?? await app.vault.read(file);
// Find every ### heading and capture the body up to the next boundary
const headingRegex = /^###\s+(.+?)\s*$/gm;
const boundaryRegex = /\n(?:#{1,6} |-{3,})/m;
const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
let m: RegExpExecArray | null;
while ((m = headingRegex.exec(content)) !== null) {
const name = m[1].toLowerCase();
const afterHeading = m.index + m[0].length;
const nextBoundary = boundaryRegex.exec(content.slice(afterHeading));
const sectionEnd = nextBoundary ? afterHeading + nextBoundary.index : content.length;
const body = content.slice(afterHeading, sectionEnd);
// Collect wiki-links
const sectionLinks: string[] = [];
let lm: RegExpExecArray | null;
const lr = new RegExp(linkRegex.source, "g");
while ((lm = lr.exec(body)) !== null) sectionLinks.push(lm[1]);
links.set(name, sectionLinks);
text.set(name, body.trim());
}
return { text, links };
}
/**
* Append a backlink to hexFilePath at the end of targetFilePath, unless
* a link to the hex file already exists anywhere in the target note.
*/
export async function addBacklinkToFile(app: App, targetFilePath: string, hexFilePath: string): Promise<void> {
const hexFile = app.vault.getAbstractFileByPath(hexFilePath);
const targetFile = app.vault.getAbstractFileByPath(targetFilePath);
if (!(hexFile instanceof TFile) || !(targetFile instanceof TFile)) return;
// Skip if the target already links back to the hex
const cache = app.metadataCache.getFileCache(targetFile);
const alreadyLinked = cache?.links?.some(
l => app.metadataCache.getFirstLinkpathDest(l.link, targetFilePath) === hexFile,
);
if (alreadyLinked) return;
const linkText = `[[${app.metadataCache.fileToLinktext(hexFile, targetFilePath)}]]`;
await app.vault.process(targetFile, (content) =>
content.trimEnd() + (content.trim() ? "\n\n" : "") + linkText + "\n",
);
}
/** Replace the body of a named ### section in-place, creating the section if absent. */
export async function setSectionContent(app: App, filePath: string, section: string, newText: string): Promise<void> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return;
await app.vault.process(file, (content) => {
const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi");
const match = headingRegex.exec(content);
if (!match) {
return newText.trim()
? content.trimEnd() + `\n\n### ${section}\n${newText.trim()}\n`
: content;
}
const afterHeading = match.index + match[0].length;
const nextBoundary = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading));
const sectionEnd = nextBoundary ? afterHeading + nextBoundary.index : content.length;
const replacement = newText.trim() ? `\n${newText.trim()}\n` : "\n";
return content.slice(0, afterHeading) + replacement + content.slice(sectionEnd);
});
}
import { App, TFile } from "obsidian";
/** Insert a wiki-link under the named ### section, creating the section if absent. */
export async function addLinkToSection(app: App, filePath: string, section: string, linkText: string): Promise<void> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return;
await app.vault.process(file, (content) => {
const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi");
const match = headingRegex.exec(content);
if (!match) {
return content.trimEnd() + `\n\n### ${section}\n\n${linkText}\n`;
}
const afterHeading = match.index + match[0].length;
const nextBoundaryMatch = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading));
const sectionEnd = nextBoundaryMatch ? afterHeading + nextBoundaryMatch.index : content.length;
const sectionContent = content.slice(afterHeading, sectionEnd);
if (sectionContent.includes(linkText)) return content;
const trimmedSection = sectionContent.trimEnd();
const insertAt = afterHeading + trimmedSection.length;
return content.slice(0, insertAt) + "\n\n" + linkText + content.slice(insertAt);
});
}
/** Remove a wiki-link from under the named ### section. Removes the whole line containing it. */
export async function removeLinkFromSection(app: App, filePath: string, section: string, linkTarget: string): Promise<void> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return;
await app.vault.process(file, (content) => {
const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi");
const match = headingRegex.exec(content);
if (!match) return content;
const afterHeading = match.index + match[0].length;
const nextBoundaryMatch = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading));
const sectionEnd = nextBoundaryMatch ? afterHeading + nextBoundaryMatch.index : content.length;
const escapedTarget = linkTarget.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const lineRegex = new RegExp(`\\n[^\\n]*\\[\\[${escapedTarget}(?:\\|[^\\]]+)?\\]\\][^\\n]*`, "g");
const sectionBody = content.slice(afterHeading, sectionEnd);
const newBody = sectionBody.replace(lineRegex, "");
return content.slice(0, afterHeading) + newBody + content.slice(sectionEnd);
});
}
/** Return all wiki-link targets found under a named ### section. */
export async function getLinksInSection(app: App, filePath: string, section: string): Promise<string[]> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return [];
const content = await app.vault.read(file);
const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi");
const match = headingRegex.exec(content);
if (!match) return [];
const afterHeading = match.index + match[0].length;
const nextBoundaryMatch = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading));
const sectionEnd = nextBoundaryMatch ? afterHeading + nextBoundaryMatch.index : content.length;
const sectionContent = content.slice(afterHeading, sectionEnd);
const links: string[] = [];
const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
let m;
while ((m = linkRegex.exec(sectionContent)) !== null) {
links.push(m[1]);
}
return links;
}
/** Return the plain text body of a named ### section (stops at next heading or ---). */
export async function getSectionContent(app: App, filePath: string, section: string): Promise<string> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return "";
const content = await app.vault.read(file);
const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi");
const match = headingRegex.exec(content);
if (!match) return "";
const afterHeading = match.index + match[0].length;
const nextBoundary = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading));
const sectionEnd = nextBoundary ? afterHeading + nextBoundary.index : content.length;
return content.slice(afterHeading, sectionEnd).trim();
}
/** Read a file once and return all text and link section content in a single pass. */
export async function getAllSectionData(
app: App,
filePath: string,
preloadedContent?: string,
): Promise<{ text: Map<string, string>; links: Map<string, string[]> }> {
const text = new Map<string, string>();
const links = new Map<string, string[]>();
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return { text, links };
const content = preloadedContent ?? await app.vault.read(file);
// Find every ### heading and capture the body up to the next boundary
const headingRegex = /^###\s+(.+?)\s*$/gm;
const boundaryRegex = /\n(?:#{1,6} |-{3,})/m;
const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
let m: RegExpExecArray | null;
while ((m = headingRegex.exec(content)) !== null) {
const name = m[1].toLowerCase();
const afterHeading = m.index + m[0].length;
const nextBoundary = boundaryRegex.exec(content.slice(afterHeading));
const sectionEnd = nextBoundary ? afterHeading + nextBoundary.index : content.length;
const body = content.slice(afterHeading, sectionEnd);
// Collect wiki-links
const sectionLinks: string[] = [];
let lm: RegExpExecArray | null;
const lr = new RegExp(linkRegex.source, "g");
while ((lm = lr.exec(body)) !== null) sectionLinks.push(lm[1]);
links.set(name, sectionLinks);
text.set(name, body.trim());
}
return { text, links };
}
/**
* Append a backlink to hexFilePath at the end of targetFilePath, unless
* a link to the hex file already exists anywhere in the target note.
*/
export async function addBacklinkToFile(app: App, targetFilePath: string, hexFilePath: string): Promise<void> {
const hexFile = app.vault.getAbstractFileByPath(hexFilePath);
const targetFile = app.vault.getAbstractFileByPath(targetFilePath);
if (!(hexFile instanceof TFile) || !(targetFile instanceof TFile)) return;
// Skip if the target already links back to the hex
const cache = app.metadataCache.getFileCache(targetFile);
const alreadyLinked = cache?.links?.some(
l => app.metadataCache.getFirstLinkpathDest(l.link, targetFilePath) === hexFile,
);
if (alreadyLinked) return;
const linkText = `[[${app.metadataCache.fileToLinktext(hexFile, targetFilePath)}]]`;
await app.vault.process(targetFile, (content) =>
content.trimEnd() + (content.trim() ? "\n\n" : "") + linkText + "\n",
);
}
/** Replace the body of a named ### section in-place, creating the section if absent. */
export async function setSectionContent(app: App, filePath: string, section: string, newText: string): Promise<void> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return;
await app.vault.process(file, (content) => {
const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi");
const match = headingRegex.exec(content);
if (!match) {
return newText.trim()
? content.trimEnd() + `\n\n### ${section}\n${newText.trim()}\n`
: content;
}
const afterHeading = match.index + match[0].length;
const nextBoundary = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading));
const sectionEnd = nextBoundary ? afterHeading + nextBoundary.index : content.length;
const replacement = newText.trim() ? `\n${newText.trim()}\n` : "\n";
return content.slice(0, afterHeading) + replacement + content.slice(sectionEnd);
});
}

View file

@ -1,74 +1,74 @@
export type PathLineStyle = "solid" | "dashed" | "dotted";
export type PathRouting = "through" | "meander" | "edge";
export interface PathType {
name: string;
color: string;
width: number; // 110, direct SVG stroke-width
lineStyle: PathLineStyle;
routing: PathRouting; // "through" = hex centers; "meander" = edge midpoints (curved); "edge" = along hex boundary lines
}
export interface PathChain {
typeName: string; // references PathType.name
hexes: string[]; // "x_y" keys
}
export interface RegionData {
name: string;
paletteName: string;
gridSize: { cols: number; rows: number };
gridOffset: { x: number; y: number };
pathChains: PathChain[];
}
export interface TerrainPalette {
name: string;
terrains: TerrainColor[];
}
export interface TerrainColor {
name: string;
color: string;
icon?: string;
iconColor?: string; // CSS colour to tint the icon; undefined = no tint (render as-is)
category?: string;
}
export interface HexmakerPluginSettings {
mySetting: string;
worldFolder: string;
hexFolder: string;
townsFolder: string;
dungeonsFolder: string;
questsFolder: string;
featuresFolder: string;
iconsFolder: string;
templatePath: string;
hexGap: string;
terrainPalettes: TerrainPalette[];
regions: RegionData[];
zoomLevel: number;
pathTypes: PathType[];
hexOrientation: "pointy" | "flat";
tablesFolder: string;
factionsFolder: string;
defaultTableDice: number;
hexEditorTerrainCollapsed: boolean;
hexEditorFeaturesCollapsed: boolean;
hexEditorNotesCollapsed: boolean;
rollTableExcludedFolders: string[];
encounterTableExcludedFolders: string[];
defaultRegion: string;
workflowsFolder: string;
}
export const LINK_SECTIONS = ["Towns", "Dungeons", "Features", "Quests", "Factions", "Encounters Table"] as const;
export type LinkSection = typeof LINK_SECTIONS[number];
export const TEXT_SECTIONS = [
{ key: "description", label: "Description" },
{ key: "landmark", label: "Landmark" },
{ key: "hidden", label: "Hidden" },
{ key: "secret", label: "Secret" },
] as const;
export type PathLineStyle = "solid" | "dashed" | "dotted";
export type PathRouting = "through" | "meander" | "edge";
export interface PathType {
name: string;
color: string;
width: number; // 110, direct SVG stroke-width
lineStyle: PathLineStyle;
routing: PathRouting; // "through" = hex centers; "meander" = edge midpoints (curved); "edge" = along hex boundary lines
}
export interface PathChain {
typeName: string; // references PathType.name
hexes: string[]; // "x_y" keys
}
export interface RegionData {
name: string;
paletteName: string;
gridSize: { cols: number; rows: number };
gridOffset: { x: number; y: number };
pathChains: PathChain[];
}
export interface TerrainPalette {
name: string;
terrains: TerrainColor[];
}
export interface TerrainColor {
name: string;
color: string;
icon?: string;
iconColor?: string; // CSS colour to tint the icon; undefined = no tint (render as-is)
category?: string;
}
export interface HexmakerPluginSettings {
mySetting: string;
worldFolder: string;
hexFolder: string;
townsFolder: string;
dungeonsFolder: string;
questsFolder: string;
featuresFolder: string;
iconsFolder: string;
templatePath: string;
hexGap: string;
terrainPalettes: TerrainPalette[];
regions: RegionData[];
zoomLevel: number;
pathTypes: PathType[];
hexOrientation: "pointy" | "flat";
tablesFolder: string;
factionsFolder: string;
defaultTableDice: number;
hexEditorTerrainCollapsed: boolean;
hexEditorFeaturesCollapsed: boolean;
hexEditorNotesCollapsed: boolean;
rollTableExcludedFolders: string[];
encounterTableExcludedFolders: string[];
defaultRegion: string;
workflowsFolder: string;
}
export const LINK_SECTIONS = ["Towns", "Dungeons", "Features", "Quests", "Factions", "Encounters Table"] as const;
export type LinkSection = typeof LINK_SECTIONS[number];
export const TEXT_SECTIONS = [
{ key: "description", label: "Description" },
{ key: "landmark", label: "Landmark" },
{ key: "hidden", label: "Hidden" },
{ key: "secret", label: "Secret" },
] as const;

View file

@ -1,63 +1,63 @@
import type HexmakerPlugin from "./HexmakerPlugin";
import { TFile, normalizePath } from "obsidian";
import { BUNDLED_ICONS } from "./bundledIcons";
export function normalizeFolder(path: string): string {
if (!path) return "";
return normalizePath(path);
}
export function makeTableTemplate(dice: number, extraFrontmatter?: Record<string, string | boolean | number>, preamble?: string): string {
const rows = "| | 1 |";
const extra = extraFrontmatter
? Object.entries(extraFrontmatter).map(([k, v]) => `${k}: ${v}`).join("\n") + "\n"
: "";
const preambleBlock = preamble ? `\n${preamble}\n` : "";
return `---\ndice: ${dice}\n${extra}---\n${preambleBlock}\n| Result | Weight |\n|--------|--------|\n${rows}\n`;
}
/**
* Creates an icon element inside `parent`.
* When `iconColor` is provided the icon is rendered as a CSS-masked div: the icon
* shape is used as a mask and `iconColor` is the fill (ideal for monochrome icons).
* Otherwise a plain <img> is used for full-colour rendering.
*/
export function createIconEl(
parent: HTMLElement,
src: string,
alt: string,
iconColor: string | undefined,
cls: string,
): HTMLElement {
if (iconColor) {
const div = parent.createEl("div", { cls, title: alt });
div.setCssProps({
'mask-image': `url("${src}")`,
'-webkit-mask-image': `url("${src}")`,
'mask-size': 'contain',
'-webkit-mask-size': 'contain',
'mask-repeat': 'no-repeat',
'-webkit-mask-repeat': 'no-repeat',
'mask-position': 'center',
'-webkit-mask-position': 'center',
'background-color': iconColor,
});
return div;
}
const img = parent.createEl("img", { cls });
img.src = src;
img.alt = alt;
return img;
}
export function getIconUrl(plugin: HexmakerPlugin, iconFilename: string): string {
if (plugin.vaultIconsSet.has(iconFilename)) {
const folder = normalizeFolder(plugin.settings.iconsFolder ?? "");
const file = plugin.app.vault.getAbstractFileByPath(`${folder}/${iconFilename}`);
if (file instanceof TFile) return plugin.app.vault.getResourcePath(file);
}
const bundled = BUNDLED_ICONS.get(iconFilename);
if (bundled) return bundled;
return plugin.app.vault.adapter.getResourcePath(`${plugin.manifest.dir}/icons/${iconFilename}`);
}
import type HexmakerPlugin from "./HexmakerPlugin";
import { TFile, normalizePath } from "obsidian";
import { BUNDLED_ICONS } from "./bundledIcons";
export function normalizeFolder(path: string): string {
if (!path) return "";
return normalizePath(path);
}
export function makeTableTemplate(dice: number, extraFrontmatter?: Record<string, string | boolean | number>, preamble?: string): string {
const rows = "| | 1 |";
const extra = extraFrontmatter
? Object.entries(extraFrontmatter).map(([k, v]) => `${k}: ${v}`).join("\n") + "\n"
: "";
const preambleBlock = preamble ? `\n${preamble}\n` : "";
return `---\ndice: ${dice}\n${extra}---\n${preambleBlock}\n| Result | Weight |\n|--------|--------|\n${rows}\n`;
}
/**
* Creates an icon element inside `parent`.
* When `iconColor` is provided the icon is rendered as a CSS-masked div: the icon
* shape is used as a mask and `iconColor` is the fill (ideal for monochrome icons).
* Otherwise a plain <img> is used for full-colour rendering.
*/
export function createIconEl(
parent: HTMLElement,
src: string,
alt: string,
iconColor: string | undefined,
cls: string,
): HTMLElement {
if (iconColor) {
const div = parent.createEl("div", { cls, title: alt });
div.setCssProps({
'mask-image': `url("${src}")`,
'-webkit-mask-image': `url("${src}")`,
'mask-size': 'contain',
'-webkit-mask-size': 'contain',
'mask-repeat': 'no-repeat',
'-webkit-mask-repeat': 'no-repeat',
'mask-position': 'center',
'-webkit-mask-position': 'center',
'background-color': iconColor,
});
return div;
}
const img = parent.createEl("img", { cls });
img.src = src;
img.alt = alt;
return img;
}
export function getIconUrl(plugin: HexmakerPlugin, iconFilename: string): string {
if (plugin.vaultIconsSet.has(iconFilename)) {
const folder = normalizeFolder(plugin.settings.iconsFolder ?? "");
const file = plugin.app.vault.getAbstractFileByPath(`${folder}/${iconFilename}`);
if (file instanceof TFile) return plugin.app.vault.getResourcePath(file);
}
const bundled = BUNDLED_ICONS.get(iconFilename);
if (bundled) return bundled;
return plugin.app.vault.adapter.getResourcePath(`${plugin.manifest.dir}/icons/${iconFilename}`);
}

5894
styles.css

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,28 @@
/** Minimal Obsidian API stubs for unit tests. */
export class TAbstractFile {
path = "";
name = "";
parent: TFolder | null = null;
}
export class TFile extends TAbstractFile {
basename = "";
extension = "md";
stat = { ctime: 0, mtime: 0, size: 0 };
}
export class TFolder extends TAbstractFile {
children: TAbstractFile[] = [];
isRoot() { return false; }
}
export class App {}
export class Modal { app: App; contentEl = { empty() {}, addClass() {}, createDiv() { return this; }, createEl() { return this; }, createSpan() { return this; }, setText() { return this; }, style: {} } as any; constructor(app: App) { this.app = app; } }
export class SuggestModal<T> { constructor(_app: App) {} getSuggestions(_q: string): T[] { return []; } renderSuggestion(_v: T, _el: HTMLElement): void {} onChooseSuggestion(_v: T, _e: MouseEvent | KeyboardEvent): void {} }
export class Notice { constructor(_msg: string) {} }
export class Plugin { constructor(_app: App, _manifest: any) {} }
export function normalizePath(path: string): string {
return path.replace(/[\\/]+/g, "/").replace(/\u00A0/g, " ").normalize().replace(/^\/+|\/+$/g, "");
}
/** Minimal Obsidian API stubs for unit tests. */
export class TAbstractFile {
path = "";
name = "";
parent: TFolder | null = null;
}
export class TFile extends TAbstractFile {
basename = "";
extension = "md";
stat = { ctime: 0, mtime: 0, size: 0 };
}
export class TFolder extends TAbstractFile {
children: TAbstractFile[] = [];
isRoot() { return false; }
}
export class App {}
export class Modal { app: App; contentEl = { empty() {}, addClass() {}, createDiv() { return this; }, createEl() { return this; }, createSpan() { return this; }, setText() { return this; }, style: {} } as any; constructor(app: App) { this.app = app; } }
export class SuggestModal<T> { constructor(_app: App) {} getSuggestions(_q: string): T[] { return []; } renderSuggestion(_v: T, _el: HTMLElement): void {} onChooseSuggestion(_v: T, _e: MouseEvent | KeyboardEvent): void {} }
export class Notice { constructor(_msg: string) {} }
export class Plugin { constructor(_app: App, _manifest: any) {} }
export function normalizePath(path: string): string {
return path.replace(/[\\/]+/g, "/").replace(/\u00A0/g, " ").normalize().replace(/^\/+|\/+$/g, "");
}

View file

@ -1,11 +1,11 @@
/** Stub loader — returns an empty-string default export for .png and .md assets. */
export async function load(url, context, nextLoad) {
if (url.endsWith(".png") || url.endsWith(".md")) {
return {
format: "module",
shortCircuit: true,
source: 'export default "";',
};
}
return nextLoad(url, context);
}
/** Stub loader — returns an empty-string default export for .png and .md assets. */
export async function load(url, context, nextLoad) {
if (url.endsWith(".png") || url.endsWith(".md")) {
return {
format: "module",
shortCircuit: true,
source: 'export default "";',
};
}
return nextLoad(url, context);
}

View file

@ -1,221 +1,221 @@
import { describe, it, mock } from "node:test";
import expect from "expect";
import { TFile } from "obsidian";
import { getTerrainFromFile, setTerrainInFile, getIconOverrideFromFile, setIconOverrideInFile } from "../src/frontmatter";
/** Build a minimal mock App backed by an in-memory string. */
function makeApp(filePath: string, initialContent: string) {
let stored = initialContent;
const file = Object.create(TFile.prototype) as TFile;
file.path = filePath;
const app = {
vault: {
getAbstractFileByPath: (p: string) => (p === filePath ? file : null),
},
fileManager: {
processFrontMatter: mock.fn(async (_f: unknown, fn: (fm: Record<string, unknown>) => void) => {
const fmMatch = stored.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
const fm: Record<string, unknown> = {};
if (fmMatch) {
for (const line of fmMatch[1].split("\n")) {
const m = line.match(/^([\w-]+):\s*(.+)$/);
if (m) fm[m[1]] = m[2].trim();
}
}
const rest = fmMatch ? stored.slice(fmMatch[0].length) : stored;
fn(fm);
const fmLines = Object.entries(fm).map(([k, v]) => `${k}: ${v}`).join("\n");
stored = fmLines ? `---\n${fmLines}\n---\n${rest}` : rest;
}),
},
metadataCache: {
getFileCache: mock.fn(() => null),
},
} as unknown as import("obsidian").App;
return { app, getContent: () => stored };
}
// ── setTerrainInFile ──────────────────────────────────────────────────────────
describe("setTerrainInFile", () => {
it("returns false when the file does not exist", async () => {
const { app } = makeApp("world/hexes/1_1.md", "");
const result = await setTerrainInFile(app, "world/hexes/MISSING.md", "forest");
expect(result).toBe(false);
});
it("adds terrain to existing frontmatter that lacks it", async () => {
const { app, getContent } = makeApp("hex.md", "---\ntitle: test\n---\n\nContent");
await setTerrainInFile(app, "hex.md", "forest");
expect(getContent()).toContain("terrain: forest");
expect(getContent()).toContain("title: test");
});
it("updates an existing terrain field", async () => {
const { app, getContent } = makeApp("hex.md", "---\nterrain: plains\n---\n\nContent");
await setTerrainInFile(app, "hex.md", "forest");
expect(getContent()).toContain("terrain: forest");
expect(getContent()).not.toContain("terrain: plains");
});
it("removes the terrain field when passed null", async () => {
const { app, getContent } = makeApp("hex.md", "---\nterrain: forest\ntitle: x\n---\n\nContent");
await setTerrainInFile(app, "hex.md", null);
expect(getContent()).not.toContain("terrain:");
expect(getContent()).toContain("title: x");
});
it("creates frontmatter when none exists", async () => {
const { app, getContent } = makeApp("hex.md", "Just content.");
await setTerrainInFile(app, "hex.md", "desert");
expect(getContent()).toMatch(/^---\n/);
expect(getContent()).toContain("terrain: desert");
expect(getContent()).toContain("Just content.");
});
it("returns true and makes no change when removing terrain from a file with no frontmatter", async () => {
const { app, getContent } = makeApp("hex.md", "No frontmatter.");
const result = await setTerrainInFile(app, "hex.md", null);
expect(result).toBe(true);
expect(getContent()).toBe("No frontmatter.");
});
it("preserves content body after the frontmatter", async () => {
const body = "\n### Towns\n\n[[Town A]]\n";
const { app, getContent } = makeApp("hex.md", `---\nterrain: plains\n---\n${body}`);
await setTerrainInFile(app, "hex.md", "forest");
expect(getContent()).toContain("### Towns");
expect(getContent()).toContain("[[Town A]]");
});
});
// ── setIconOverrideInFile ─────────────────────────────────────────────────────
describe("setIconOverrideInFile", () => {
it("returns false when the file does not exist", async () => {
const { app } = makeApp("hex.md", "");
const result = await setIconOverrideInFile(app, "MISSING.md", "castle.png");
expect(result).toBe(false);
});
it("adds icon to existing frontmatter that lacks it", async () => {
const { app, getContent } = makeApp("hex.md", "---\nterrain: forest\n---\n\nContent");
await setIconOverrideInFile(app, "hex.md", "castle.png");
expect(getContent()).toContain("icon: castle.png");
expect(getContent()).toContain("terrain: forest");
});
it("updates an existing icon field", async () => {
const { app, getContent } = makeApp("hex.md", "---\nicon: tower.png\n---\n\nContent");
await setIconOverrideInFile(app, "hex.md", "castle.png");
expect(getContent()).toContain("icon: castle.png");
expect(getContent()).not.toContain("icon: tower.png");
});
it("removes the icon field when passed null", async () => {
const { app, getContent } = makeApp("hex.md", "---\nicon: castle.png\nterrain: forest\n---\n\nContent");
await setIconOverrideInFile(app, "hex.md", null);
expect(getContent()).not.toContain("icon:");
expect(getContent()).toContain("terrain: forest");
});
it("creates frontmatter when none exists", async () => {
const { app, getContent } = makeApp("hex.md", "Bare content.");
await setIconOverrideInFile(app, "hex.md", "ruin.png");
expect(getContent()).toMatch(/^---\n/);
expect(getContent()).toContain("icon: ruin.png");
expect(getContent()).toContain("Bare content.");
});
it("returns true and makes no change when removing icon from a file with no frontmatter", async () => {
const { app, getContent } = makeApp("hex.md", "No frontmatter.");
const result = await setIconOverrideInFile(app, "hex.md", null);
expect(result).toBe(true);
expect(getContent()).toBe("No frontmatter.");
});
});
// ── helpers for cache-based reads ────────────────────────────────────────────
/** Build a minimal mock App that returns a metadata cache with the given frontmatter. */
function makeAppWithCache(filePath: string, frontmatter: Record<string, unknown> | null) {
const file = Object.create(TFile.prototype) as TFile;
file.path = filePath;
const app = {
vault: {
getAbstractFileByPath: (p: string) => (p === filePath ? file : null),
},
metadataCache: {
getFileCache: mock.fn(() => (frontmatter !== null ? { frontmatter } : null)),
},
} as unknown as import("obsidian").App;
return { app };
}
// ── getTerrainFromFile ────────────────────────────────────────────────────────
describe("getTerrainFromFile", () => {
it("returns null when the file does not exist", () => {
const { app } = makeAppWithCache("hex.md", { terrain: "forest" });
expect(getTerrainFromFile(app, "MISSING.md")).toBeNull();
});
it("returns the terrain string from the metadata cache", () => {
const { app } = makeAppWithCache("hex.md", { terrain: "forest" });
expect(getTerrainFromFile(app, "hex.md")).toBe("forest");
});
it("returns null when terrain field is absent from frontmatter", () => {
const { app } = makeAppWithCache("hex.md", { title: "Test" });
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
it("returns null when there is no file cache", () => {
const { app } = makeAppWithCache("hex.md", null);
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
it("returns null when terrain field is not a string (e.g. array)", () => {
const { app } = makeAppWithCache("hex.md", { terrain: ["forest", "plains"] });
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
it("returns null when terrain field is a number", () => {
const { app } = makeAppWithCache("hex.md", { terrain: 42 });
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
});
// ── getIconOverrideFromFile ───────────────────────────────────────────────────
describe("getIconOverrideFromFile", () => {
it("returns null when the file does not exist", () => {
const { app } = makeAppWithCache("hex.md", { icon: "castle.png" });
expect(getIconOverrideFromFile(app, "MISSING.md")).toBeNull();
});
it("returns the icon string from the metadata cache", () => {
const { app } = makeAppWithCache("hex.md", { icon: "castle.png" });
expect(getIconOverrideFromFile(app, "hex.md")).toBe("castle.png");
});
it("returns null when icon field is absent from frontmatter", () => {
const { app } = makeAppWithCache("hex.md", { terrain: "forest" });
expect(getIconOverrideFromFile(app, "hex.md")).toBeNull();
});
it("returns null when there is no file cache", () => {
const { app } = makeAppWithCache("hex.md", null);
expect(getIconOverrideFromFile(app, "hex.md")).toBeNull();
});
it("returns null when icon field is not a string (e.g. boolean)", () => {
const { app } = makeAppWithCache("hex.md", { icon: true });
expect(getIconOverrideFromFile(app, "hex.md")).toBeNull();
});
});
import { describe, it, mock } from "node:test";
import expect from "expect";
import { TFile } from "obsidian";
import { getTerrainFromFile, setTerrainInFile, getIconOverrideFromFile, setIconOverrideInFile } from "../src/frontmatter";
/** Build a minimal mock App backed by an in-memory string. */
function makeApp(filePath: string, initialContent: string) {
let stored = initialContent;
const file = Object.create(TFile.prototype) as TFile;
file.path = filePath;
const app = {
vault: {
getAbstractFileByPath: (p: string) => (p === filePath ? file : null),
},
fileManager: {
processFrontMatter: mock.fn(async (_f: unknown, fn: (fm: Record<string, unknown>) => void) => {
const fmMatch = stored.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
const fm: Record<string, unknown> = {};
if (fmMatch) {
for (const line of fmMatch[1].split("\n")) {
const m = line.match(/^([\w-]+):\s*(.+)$/);
if (m) fm[m[1]] = m[2].trim();
}
}
const rest = fmMatch ? stored.slice(fmMatch[0].length) : stored;
fn(fm);
const fmLines = Object.entries(fm).map(([k, v]) => `${k}: ${v}`).join("\n");
stored = fmLines ? `---\n${fmLines}\n---\n${rest}` : rest;
}),
},
metadataCache: {
getFileCache: mock.fn(() => null),
},
} as unknown as import("obsidian").App;
return { app, getContent: () => stored };
}
// ── setTerrainInFile ──────────────────────────────────────────────────────────
describe("setTerrainInFile", () => {
it("returns false when the file does not exist", async () => {
const { app } = makeApp("world/hexes/1_1.md", "");
const result = await setTerrainInFile(app, "world/hexes/MISSING.md", "forest");
expect(result).toBe(false);
});
it("adds terrain to existing frontmatter that lacks it", async () => {
const { app, getContent } = makeApp("hex.md", "---\ntitle: test\n---\n\nContent");
await setTerrainInFile(app, "hex.md", "forest");
expect(getContent()).toContain("terrain: forest");
expect(getContent()).toContain("title: test");
});
it("updates an existing terrain field", async () => {
const { app, getContent } = makeApp("hex.md", "---\nterrain: plains\n---\n\nContent");
await setTerrainInFile(app, "hex.md", "forest");
expect(getContent()).toContain("terrain: forest");
expect(getContent()).not.toContain("terrain: plains");
});
it("removes the terrain field when passed null", async () => {
const { app, getContent } = makeApp("hex.md", "---\nterrain: forest\ntitle: x\n---\n\nContent");
await setTerrainInFile(app, "hex.md", null);
expect(getContent()).not.toContain("terrain:");
expect(getContent()).toContain("title: x");
});
it("creates frontmatter when none exists", async () => {
const { app, getContent } = makeApp("hex.md", "Just content.");
await setTerrainInFile(app, "hex.md", "desert");
expect(getContent()).toMatch(/^---\n/);
expect(getContent()).toContain("terrain: desert");
expect(getContent()).toContain("Just content.");
});
it("returns true and makes no change when removing terrain from a file with no frontmatter", async () => {
const { app, getContent } = makeApp("hex.md", "No frontmatter.");
const result = await setTerrainInFile(app, "hex.md", null);
expect(result).toBe(true);
expect(getContent()).toBe("No frontmatter.");
});
it("preserves content body after the frontmatter", async () => {
const body = "\n### Towns\n\n[[Town A]]\n";
const { app, getContent } = makeApp("hex.md", `---\nterrain: plains\n---\n${body}`);
await setTerrainInFile(app, "hex.md", "forest");
expect(getContent()).toContain("### Towns");
expect(getContent()).toContain("[[Town A]]");
});
});
// ── setIconOverrideInFile ─────────────────────────────────────────────────────
describe("setIconOverrideInFile", () => {
it("returns false when the file does not exist", async () => {
const { app } = makeApp("hex.md", "");
const result = await setIconOverrideInFile(app, "MISSING.md", "castle.png");
expect(result).toBe(false);
});
it("adds icon to existing frontmatter that lacks it", async () => {
const { app, getContent } = makeApp("hex.md", "---\nterrain: forest\n---\n\nContent");
await setIconOverrideInFile(app, "hex.md", "castle.png");
expect(getContent()).toContain("icon: castle.png");
expect(getContent()).toContain("terrain: forest");
});
it("updates an existing icon field", async () => {
const { app, getContent } = makeApp("hex.md", "---\nicon: tower.png\n---\n\nContent");
await setIconOverrideInFile(app, "hex.md", "castle.png");
expect(getContent()).toContain("icon: castle.png");
expect(getContent()).not.toContain("icon: tower.png");
});
it("removes the icon field when passed null", async () => {
const { app, getContent } = makeApp("hex.md", "---\nicon: castle.png\nterrain: forest\n---\n\nContent");
await setIconOverrideInFile(app, "hex.md", null);
expect(getContent()).not.toContain("icon:");
expect(getContent()).toContain("terrain: forest");
});
it("creates frontmatter when none exists", async () => {
const { app, getContent } = makeApp("hex.md", "Bare content.");
await setIconOverrideInFile(app, "hex.md", "ruin.png");
expect(getContent()).toMatch(/^---\n/);
expect(getContent()).toContain("icon: ruin.png");
expect(getContent()).toContain("Bare content.");
});
it("returns true and makes no change when removing icon from a file with no frontmatter", async () => {
const { app, getContent } = makeApp("hex.md", "No frontmatter.");
const result = await setIconOverrideInFile(app, "hex.md", null);
expect(result).toBe(true);
expect(getContent()).toBe("No frontmatter.");
});
});
// ── helpers for cache-based reads ────────────────────────────────────────────
/** Build a minimal mock App that returns a metadata cache with the given frontmatter. */
function makeAppWithCache(filePath: string, frontmatter: Record<string, unknown> | null) {
const file = Object.create(TFile.prototype) as TFile;
file.path = filePath;
const app = {
vault: {
getAbstractFileByPath: (p: string) => (p === filePath ? file : null),
},
metadataCache: {
getFileCache: mock.fn(() => (frontmatter !== null ? { frontmatter } : null)),
},
} as unknown as import("obsidian").App;
return { app };
}
// ── getTerrainFromFile ────────────────────────────────────────────────────────
describe("getTerrainFromFile", () => {
it("returns null when the file does not exist", () => {
const { app } = makeAppWithCache("hex.md", { terrain: "forest" });
expect(getTerrainFromFile(app, "MISSING.md")).toBeNull();
});
it("returns the terrain string from the metadata cache", () => {
const { app } = makeAppWithCache("hex.md", { terrain: "forest" });
expect(getTerrainFromFile(app, "hex.md")).toBe("forest");
});
it("returns null when terrain field is absent from frontmatter", () => {
const { app } = makeAppWithCache("hex.md", { title: "Test" });
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
it("returns null when there is no file cache", () => {
const { app } = makeAppWithCache("hex.md", null);
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
it("returns null when terrain field is not a string (e.g. array)", () => {
const { app } = makeAppWithCache("hex.md", { terrain: ["forest", "plains"] });
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
it("returns null when terrain field is a number", () => {
const { app } = makeAppWithCache("hex.md", { terrain: 42 });
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
});
// ── getIconOverrideFromFile ───────────────────────────────────────────────────
describe("getIconOverrideFromFile", () => {
it("returns null when the file does not exist", () => {
const { app } = makeAppWithCache("hex.md", { icon: "castle.png" });
expect(getIconOverrideFromFile(app, "MISSING.md")).toBeNull();
});
it("returns the icon string from the metadata cache", () => {
const { app } = makeAppWithCache("hex.md", { icon: "castle.png" });
expect(getIconOverrideFromFile(app, "hex.md")).toBe("castle.png");
});
it("returns null when icon field is absent from frontmatter", () => {
const { app } = makeAppWithCache("hex.md", { terrain: "forest" });
expect(getIconOverrideFromFile(app, "hex.md")).toBeNull();
});
it("returns null when there is no file cache", () => {
const { app } = makeAppWithCache("hex.md", null);
expect(getIconOverrideFromFile(app, "hex.md")).toBeNull();
});
it("returns null when icon field is not a string (e.g. boolean)", () => {
const { app } = makeAppWithCache("hex.md", { icon: true });
expect(getIconOverrideFromFile(app, "hex.md")).toBeNull();
});
});

View file

@ -1,172 +1,172 @@
import { describe, it, mock } from "node:test";
import expect from "expect";
import { TFile } from "obsidian";
import { HexEditorModal } from "../src/hex-map/HexEditorModal";
/** Minimal App backed by a map of path → content strings. */
function makeApp(files: Record<string, string>) {
const fileObjs = new Map<string, TFile>();
for (const path of Object.keys(files)) {
const f = Object.create(TFile.prototype) as TFile;
f.path = path;
f.name = path.split("/").pop()!;
f.basename = f.name.replace(/\.md$/, "");
fileObjs.set(path, f);
}
return {
vault: {
getAbstractFileByPath: (p: string) => fileObjs.get(p) ?? null,
read: mock.fn(async (f: TFile) => files[f.path] ?? ""),
},
metadataCache: {
getFileCache: mock.fn(() => null),
},
} as unknown as import("obsidian").App;
}
/** Minimal plugin stub — only what loadData() needs. */
function makePlugin(hexPathFn: (x: number, y: number) => string) {
return {
hexPath: mock.fn(hexPathFn),
settings: {
terrainPalette: [],
tablesFolder: "tables",
hexEditorTerrainCollapsed: false,
hexEditorFeaturesCollapsed: false,
hexEditorNotesCollapsed: false,
hexEditorStartCollapsed: false,
},
availableIcons: [],
} as unknown as import("../src/HexmakerPlugin").default;
}
// ── loadData ──────────────────────────────────────────────────────────────────
describe("HexEditorModal.loadData", () => {
it("sets hexExists to false when the hex file does not exist", async () => {
const app = makeApp({});
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).hexExists).toBe(false);
});
it("sets hexExists to true when the hex file exists", async () => {
const app = makeApp({ "hex/1_1.md": "---\nterrain: forest\n---\n\n" });
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).hexExists).toBe(true);
});
it("extracts directTerrain from frontmatter", async () => {
const app = makeApp({ "hex/2_3.md": "---\nterrain: desert\n---\n\nBody." });
const plugin = makePlugin(() => "hex/2_3.md");
const modal = new HexEditorModal(app, plugin, 2, 3, "default", () => {});
await modal.loadData();
expect((modal as any).directTerrain).toBe("desert");
});
it("extracts directIcon from frontmatter", async () => {
const app = makeApp({ "hex/4_5.md": "---\nterrain: forest\nicon: castle.png\n---\n\n" });
const plugin = makePlugin(() => "hex/4_5.md");
const modal = new HexEditorModal(app, plugin, 4, 5, "default", () => {});
await modal.loadData();
expect((modal as any).directIcon).toBe("castle.png");
});
it("leaves directTerrain null when frontmatter has no terrain field", async () => {
const app = makeApp({ "hex/1_1.md": "---\ntitle: test\n---\n\n" });
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).directTerrain).toBeNull();
});
it("leaves directIcon null when frontmatter has no icon field", async () => {
const app = makeApp({ "hex/1_1.md": "---\nterrain: forest\n---\n\n" });
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).directIcon).toBeNull();
});
it("populates allText from sections", async () => {
const app = makeApp({
"hex/3_3.md": "---\nterrain: grass\n---\n\n### Description\n\nA grassy plain.\n\n### Landmark\n\nA tall oak.\n",
});
const plugin = makePlugin(() => "hex/3_3.md");
const modal = new HexEditorModal(app, plugin, 3, 3, "default", () => {});
await modal.loadData();
expect((modal as any).allText.get("description")).toBe("A grassy plain.");
expect((modal as any).allText.get("landmark")).toBe("A tall oak.");
});
it("populates allLinks with wiki-links from link sections", async () => {
const app = makeApp({
"hex/5_5.md": "---\nterrain: forest\n---\n\n### Encounters Table\n\n[[tables/terrain/forest - encounters]]\n",
});
const plugin = makePlugin(() => "hex/5_5.md");
const modal = new HexEditorModal(app, plugin, 5, 5, "default", () => {});
await modal.loadData();
const links = (modal as any).allLinks.get("encounters table") as string[];
expect(links).toContain("tables/terrain/forest - encounters");
});
});
// ── Navigation: reload on hex change ─────────────────────────────────────────
describe("HexEditorModal navigation reload", () => {
it("reloads data for the new hex after x/y are updated", async () => {
const app = makeApp({
"hex/1_1.md": "---\nterrain: forest\n---\n\n",
"hex/2_2.md": "---\nterrain: desert\n---\n\n",
});
const plugin = makePlugin((x, y) => `hex/${x}_${y}.md`);
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).directTerrain).toBe("forest");
// Simulate navigation to a neighbour hex
(modal as any).x = 2;
(modal as any).y = 2;
await modal.loadData();
expect((modal as any).directTerrain).toBe("desert");
});
it("clears terrain data when navigating to a hex with no file", async () => {
const app = makeApp({
"hex/1_1.md": "---\nterrain: forest\n---\n\n",
});
const plugin = makePlugin((x, y) => `hex/${x}_${y}.md`);
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).hexExists).toBe(true);
(modal as any).x = 9;
(modal as any).y = 9;
await modal.loadData();
expect((modal as any).hexExists).toBe(false);
expect((modal as any).directTerrain).toBeNull();
});
it("loads correct icon when navigating between hexes with different icons", async () => {
const app = makeApp({
"hex/1_1.md": "---\nterrain: forest\nicon: tower.png\n---\n\n",
"hex/2_1.md": "---\nterrain: desert\nicon: oasis.png\n---\n\n",
});
const plugin = makePlugin((x, y) => `hex/${x}_${y}.md`);
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).directIcon).toBe("tower.png");
(modal as any).x = 2;
(modal as any).y = 1;
await modal.loadData();
expect((modal as any).directIcon).toBe("oasis.png");
});
});
import { describe, it, mock } from "node:test";
import expect from "expect";
import { TFile } from "obsidian";
import { HexEditorModal } from "../src/hex-map/HexEditorModal";
/** Minimal App backed by a map of path → content strings. */
function makeApp(files: Record<string, string>) {
const fileObjs = new Map<string, TFile>();
for (const path of Object.keys(files)) {
const f = Object.create(TFile.prototype) as TFile;
f.path = path;
f.name = path.split("/").pop()!;
f.basename = f.name.replace(/\.md$/, "");
fileObjs.set(path, f);
}
return {
vault: {
getAbstractFileByPath: (p: string) => fileObjs.get(p) ?? null,
read: mock.fn(async (f: TFile) => files[f.path] ?? ""),
},
metadataCache: {
getFileCache: mock.fn(() => null),
},
} as unknown as import("obsidian").App;
}
/** Minimal plugin stub — only what loadData() needs. */
function makePlugin(hexPathFn: (x: number, y: number) => string) {
return {
hexPath: mock.fn(hexPathFn),
settings: {
terrainPalette: [],
tablesFolder: "tables",
hexEditorTerrainCollapsed: false,
hexEditorFeaturesCollapsed: false,
hexEditorNotesCollapsed: false,
hexEditorStartCollapsed: false,
},
availableIcons: [],
} as unknown as import("../src/HexmakerPlugin").default;
}
// ── loadData ──────────────────────────────────────────────────────────────────
describe("HexEditorModal.loadData", () => {
it("sets hexExists to false when the hex file does not exist", async () => {
const app = makeApp({});
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).hexExists).toBe(false);
});
it("sets hexExists to true when the hex file exists", async () => {
const app = makeApp({ "hex/1_1.md": "---\nterrain: forest\n---\n\n" });
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).hexExists).toBe(true);
});
it("extracts directTerrain from frontmatter", async () => {
const app = makeApp({ "hex/2_3.md": "---\nterrain: desert\n---\n\nBody." });
const plugin = makePlugin(() => "hex/2_3.md");
const modal = new HexEditorModal(app, plugin, 2, 3, "default", () => {});
await modal.loadData();
expect((modal as any).directTerrain).toBe("desert");
});
it("extracts directIcon from frontmatter", async () => {
const app = makeApp({ "hex/4_5.md": "---\nterrain: forest\nicon: castle.png\n---\n\n" });
const plugin = makePlugin(() => "hex/4_5.md");
const modal = new HexEditorModal(app, plugin, 4, 5, "default", () => {});
await modal.loadData();
expect((modal as any).directIcon).toBe("castle.png");
});
it("leaves directTerrain null when frontmatter has no terrain field", async () => {
const app = makeApp({ "hex/1_1.md": "---\ntitle: test\n---\n\n" });
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).directTerrain).toBeNull();
});
it("leaves directIcon null when frontmatter has no icon field", async () => {
const app = makeApp({ "hex/1_1.md": "---\nterrain: forest\n---\n\n" });
const plugin = makePlugin(() => "hex/1_1.md");
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).directIcon).toBeNull();
});
it("populates allText from sections", async () => {
const app = makeApp({
"hex/3_3.md": "---\nterrain: grass\n---\n\n### Description\n\nA grassy plain.\n\n### Landmark\n\nA tall oak.\n",
});
const plugin = makePlugin(() => "hex/3_3.md");
const modal = new HexEditorModal(app, plugin, 3, 3, "default", () => {});
await modal.loadData();
expect((modal as any).allText.get("description")).toBe("A grassy plain.");
expect((modal as any).allText.get("landmark")).toBe("A tall oak.");
});
it("populates allLinks with wiki-links from link sections", async () => {
const app = makeApp({
"hex/5_5.md": "---\nterrain: forest\n---\n\n### Encounters Table\n\n[[tables/terrain/forest - encounters]]\n",
});
const plugin = makePlugin(() => "hex/5_5.md");
const modal = new HexEditorModal(app, plugin, 5, 5, "default", () => {});
await modal.loadData();
const links = (modal as any).allLinks.get("encounters table") as string[];
expect(links).toContain("tables/terrain/forest - encounters");
});
});
// ── Navigation: reload on hex change ─────────────────────────────────────────
describe("HexEditorModal navigation reload", () => {
it("reloads data for the new hex after x/y are updated", async () => {
const app = makeApp({
"hex/1_1.md": "---\nterrain: forest\n---\n\n",
"hex/2_2.md": "---\nterrain: desert\n---\n\n",
});
const plugin = makePlugin((x, y) => `hex/${x}_${y}.md`);
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).directTerrain).toBe("forest");
// Simulate navigation to a neighbour hex
(modal as any).x = 2;
(modal as any).y = 2;
await modal.loadData();
expect((modal as any).directTerrain).toBe("desert");
});
it("clears terrain data when navigating to a hex with no file", async () => {
const app = makeApp({
"hex/1_1.md": "---\nterrain: forest\n---\n\n",
});
const plugin = makePlugin((x, y) => `hex/${x}_${y}.md`);
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).hexExists).toBe(true);
(modal as any).x = 9;
(modal as any).y = 9;
await modal.loadData();
expect((modal as any).hexExists).toBe(false);
expect((modal as any).directTerrain).toBeNull();
});
it("loads correct icon when navigating between hexes with different icons", async () => {
const app = makeApp({
"hex/1_1.md": "---\nterrain: forest\nicon: tower.png\n---\n\n",
"hex/2_1.md": "---\nterrain: desert\nicon: oasis.png\n---\n\n",
});
const plugin = makePlugin((x, y) => `hex/${x}_${y}.md`);
const modal = new HexEditorModal(app, plugin, 1, 1, "default", () => {});
await modal.loadData();
expect((modal as any).directIcon).toBe("tower.png");
(modal as any).x = 2;
(modal as any).y = 1;
await modal.loadData();
expect((modal as any).directIcon).toBe("oasis.png");
});
});

View file

@ -1,11 +1,11 @@
import { register, createRequire } from "node:module";
// Stub .png and .md for CJS mode (tsx CLI uses CJS transforms; without this, tsx's
// .js fallback handler tries to parse binary/markdown files as TypeScript and crashes).
const req = createRequire(import.meta.url);
const CJSModule = req("module");
CJSModule._extensions[".png"] = function (mod) { mod.exports = ""; };
CJSModule._extensions[".md"] = function (mod) { mod.exports = ""; };
// Register ESM load hook for .png/.md (fallback for ESM mode)
register("./asset-hooks.mjs", import.meta.url);
import { register, createRequire } from "node:module";
// Stub .png and .md for CJS mode (tsx CLI uses CJS transforms; without this, tsx's
// .js fallback handler tries to parse binary/markdown files as TypeScript and crashes).
const req = createRequire(import.meta.url);
const CJSModule = req("module");
CJSModule._extensions[".png"] = function (mod) { mod.exports = ""; };
CJSModule._extensions[".md"] = function (mod) { mod.exports = ""; };
// Register ESM load hook for .png/.md (fallback for ESM mode)
register("./asset-hooks.mjs", import.meta.url);

View file

@ -1,396 +1,396 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";
import expect from "expect";
import { TFile } from "obsidian";
import {
addLinkToSection,
removeLinkFromSection,
getLinksInSection,
getSectionContent,
getAllSectionData,
setSectionContent,
addBacklinkToFile,
} from "../src/sections";
/** Build a minimal mock App backed by an in-memory string. */
function makeApp(filePath: string, initialContent: string) {
let stored = initialContent;
const file = Object.create(TFile.prototype) as TFile;
file.path = filePath;
const app = {
vault: {
getAbstractFileByPath: (p: string) => (p === filePath ? file : null),
read: mock.fn(async () => stored),
process: mock.fn(async (_f: unknown, fn: (s: string) => string) => { stored = fn(stored); return stored; }),
},
metadataCache: {
getFileCache: mock.fn(() => null),
getFirstLinkpathDest: mock.fn(() => null),
fileToLinktext: mock.fn((f: TFile) => f.path),
},
} as unknown as import("obsidian").App;
return { app, getContent: () => stored };
}
// ── addLinkToSection ──────────────────────────────────────────────────────────
describe("addLinkToSection", () => {
it("appends a link under an existing section", async () => {
const { app, getContent } = makeApp("hex.md", "### Towns\n\n[[Riverdale]]\n");
await addLinkToSection(app, "hex.md", "Towns", "[[Millhaven]]");
expect(getContent()).toContain("[[Millhaven]]");
expect(getContent()).toContain("[[Riverdale]]");
});
it("creates the section when it does not exist", async () => {
const { app, getContent } = makeApp("hex.md", "Some content.");
await addLinkToSection(app, "hex.md", "Towns", "[[Newtown]]");
expect(getContent()).toContain("### Towns");
expect(getContent()).toContain("[[Newtown]]");
});
it("does not add a duplicate link", async () => {
const { app, getContent } = makeApp("hex.md", "### Towns\n\n[[Riverdale]]\n");
await addLinkToSection(app, "hex.md", "Towns", "[[Riverdale]]");
const count = (getContent().match(/\[\[Riverdale\]\]/g) ?? []).length;
expect(count).toBe(1);
});
it("does not modify other sections", async () => {
const { app, getContent } = makeApp("hex.md", "### Dungeons\n\n[[Cave]]\n\n### Towns\n\n");
await addLinkToSection(app, "hex.md", "Towns", "[[Village]]");
expect(getContent()).toContain("[[Cave]]");
});
it("is a no-op when file does not exist", async () => {
const { app } = makeApp("hex.md", "");
// Should not throw
await expect(addLinkToSection(app, "MISSING.md", "Towns", "[[X]]")).resolves.toBeUndefined();
});
it("inserts link before the --- separator, not after it", async () => {
// Template structure: ### Towns\n\n---\n\n### Dungeons
const { app, getContent } = makeApp("hex.md", "### Towns\n\n---\n\n### Dungeons\n\n");
await addLinkToSection(app, "hex.md", "Towns", "[[Millhaven]]");
const content = getContent();
const townsIdx = content.indexOf("### Towns");
const linkIdx = content.indexOf("[[Millhaven]]");
const hrIdx = content.indexOf("---");
const dungeonsIdx = content.indexOf("### Dungeons");
// Link must appear between the heading and the --- separator
expect(linkIdx).toBeGreaterThan(townsIdx);
expect(linkIdx).toBeLessThan(hrIdx);
// --- and ### Dungeons must remain after the link
expect(hrIdx).toBeLessThan(dungeonsIdx);
});
it("inserts second link before the --- separator when section already has a link", async () => {
const { app, getContent } = makeApp(
"hex.md",
"### Towns\n\n[[Riverdale]]\n\n---\n\n### Dungeons\n\n",
);
await addLinkToSection(app, "hex.md", "Towns", "[[Millhaven]]");
const content = getContent();
const hrIdx = content.indexOf("---");
const millIdx = content.indexOf("[[Millhaven]]");
const riverIdx = content.indexOf("[[Riverdale]]");
expect(riverIdx).toBeLessThan(hrIdx);
expect(millIdx).toBeLessThan(hrIdx);
});
});
// ── removeLinkFromSection ─────────────────────────────────────────────────────
describe("removeLinkFromSection", () => {
it("removes an existing link from a section", async () => {
const { app, getContent } = makeApp("hex.md", "### Towns\n\n[[Riverdale]]\n[[Millhaven]]\n");
await removeLinkFromSection(app, "hex.md", "Towns", "Riverdale");
expect(getContent()).not.toContain("[[Riverdale]]");
expect(getContent()).toContain("[[Millhaven]]");
});
it("is a no-op when the link is not present", async () => {
const original = "### Towns\n\n[[Millhaven]]\n";
const { app, getContent } = makeApp("hex.md", original);
await removeLinkFromSection(app, "hex.md", "Towns", "Missing");
expect(getContent()).toBe(original);
});
it("is a no-op when the section does not exist", async () => {
const original = "### Dungeons\n\n[[Cave]]\n";
const { app, getContent } = makeApp("hex.md", original);
await removeLinkFromSection(app, "hex.md", "Towns", "Cave");
expect(getContent()).toBe(original);
});
it("does not remove a link from a different section", async () => {
const { app, getContent } = makeApp("hex.md", "### Towns\n\n[[Village]]\n\n### Dungeons\n\n[[Village]]\n");
await removeLinkFromSection(app, "hex.md", "Towns", "Village");
// Link in Dungeons should survive
expect(getContent()).toContain("### Dungeons");
const dungeonSection = getContent().split("### Dungeons")[1];
expect(dungeonSection).toContain("[[Village]]");
});
});
// ── getLinksInSection ─────────────────────────────────────────────────────────
describe("getLinksInSection", () => {
it("returns all wiki-links in the section", async () => {
const { app } = makeApp("hex.md", "### Towns\n\n[[Riverdale]]\n[[Millhaven]]\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual(["Riverdale", "Millhaven"]);
});
it("returns empty array when section has no links", async () => {
const { app } = makeApp("hex.md", "### Towns\n\nJust text, no links.\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual([]);
});
it("returns empty array when section does not exist", async () => {
const { app } = makeApp("hex.md", "### Dungeons\n\n[[Cave]]\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual([]);
});
it("returns empty array when file does not exist", async () => {
const { app } = makeApp("hex.md", "");
const links = await getLinksInSection(app, "MISSING.md", "Towns");
expect(links).toEqual([]);
});
it("handles links with display text (pipe syntax)", async () => {
const { app } = makeApp("hex.md", "### Towns\n\n[[path/to/Town|Town Name]]\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual(["path/to/Town"]);
});
it("stops at the next heading", async () => {
const { app } = makeApp("hex.md", "### Towns\n\n[[A]]\n\n### Dungeons\n\n[[B]]\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual(["A"]);
});
it("stops at a horizontal rule (--- separator)", async () => {
// Matches default hex template structure where sections are separated by ---
const { app } = makeApp("hex.md", "### Towns\n\n[[A]]\n\n---\n\n### Dungeons\n\n[[B]]\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual(["A"]);
});
});
// ── getSectionContent ─────────────────────────────────────────────────────────
describe("getSectionContent", () => {
it("returns the trimmed body of a section", async () => {
const { app } = makeApp("hex.md", "### Description\n\nA misty valley.\n");
const content = await getSectionContent(app, "hex.md", "Description");
expect(content).toBe("A misty valley.");
});
it("returns empty string when section does not exist", async () => {
const { app } = makeApp("hex.md", "### Other\n\nSomething\n");
const content = await getSectionContent(app, "hex.md", "Description");
expect(content).toBe("");
});
it("returns empty string when file does not exist", async () => {
const { app } = makeApp("hex.md", "");
const content = await getSectionContent(app, "MISSING.md", "Description");
expect(content).toBe("");
});
it("stops at the next heading", async () => {
const { app } = makeApp("hex.md", "### Description\n\nLine one.\n\n### Notes\n\nLine two.\n");
const content = await getSectionContent(app, "hex.md", "Description");
expect(content).toBe("Line one.");
expect(content).not.toContain("Line two");
});
it("stops at a horizontal rule", async () => {
const { app } = makeApp("hex.md", "### Description\n\nBefore rule.\n\n---\n\nAfter rule.\n");
const content = await getSectionContent(app, "hex.md", "Description");
expect(content).toBe("Before rule.");
});
});
// ── getAllSectionData ─────────────────────────────────────────────────────────
describe("getAllSectionData", () => {
it("returns empty maps for a file with no sections", async () => {
const { app } = makeApp("hex.md", "Just prose, no headings.");
const { text, links } = await getAllSectionData(app, "hex.md");
expect(text.size).toBe(0);
expect(links.size).toBe(0);
});
it("returns empty maps when file does not exist", async () => {
const { app } = makeApp("hex.md", "");
const { text, links } = await getAllSectionData(app, "MISSING.md");
expect(text.size).toBe(0);
expect(links.size).toBe(0);
});
it("captures text and links from multiple sections", async () => {
const content = [
"### Description",
"",
"Foggy mountains.",
"",
"### Towns",
"",
"[[Riverdale]]",
"[[Millhaven]]",
].join("\n");
const { app } = makeApp("hex.md", content);
const { text, links } = await getAllSectionData(app, "hex.md");
expect(text.get("description")).toBe("Foggy mountains.");
expect(links.get("towns")).toEqual(["Riverdale", "Millhaven"]);
});
it("uses lowercase keys for section names", async () => {
const { app } = makeApp("hex.md", "### My Section\n\nHello.\n");
const { text } = await getAllSectionData(app, "hex.md");
expect(text.has("my section")).toBe(true);
});
});
// ── setSectionContent ─────────────────────────────────────────────────────────
describe("setSectionContent", () => {
it("replaces the body of an existing section", async () => {
const { app, getContent } = makeApp("hex.md", "### Description\n\nOld text.\n");
await setSectionContent(app, "hex.md", "Description", "New text.");
expect(getContent()).toContain("New text.");
expect(getContent()).not.toContain("Old text.");
});
it("creates the section when it does not exist", async () => {
const { app, getContent } = makeApp("hex.md", "Some content.");
await setSectionContent(app, "hex.md", "Notes", "My note.");
expect(getContent()).toContain("### Notes");
expect(getContent()).toContain("My note.");
});
it("clears section body when new text is empty", async () => {
const { app, getContent } = makeApp("hex.md", "### Description\n\nOld text.\n");
await setSectionContent(app, "hex.md", "Description", "");
expect(getContent()).not.toContain("Old text.");
});
it("does not create a section for empty new text", async () => {
const original = "No sections here.";
const { app, getContent } = makeApp("hex.md", original);
await setSectionContent(app, "hex.md", "Notes", "");
expect(getContent()).toBe(original);
});
it("does not affect adjacent sections", async () => {
const { app, getContent } = makeApp("hex.md",
"### Description\n\nOld.\n\n### Towns\n\n[[A]]\n",
);
await setSectionContent(app, "hex.md", "Description", "Updated.");
expect(getContent()).toContain("### Towns");
expect(getContent()).toContain("[[A]]");
});
it("is a no-op when file does not exist", async () => {
const { app } = makeApp("hex.md", "");
await expect(setSectionContent(app, "MISSING.md", "Description", "text")).resolves.toBeUndefined();
});
});
// ── addBacklinkToFile ─────────────────────────────────────────────────────────
/** Build an app with two independent in-memory files for backlink tests. */
function makeAppForBacklink(
hexPath: string,
hexContent: string,
targetPath: string,
targetContent: string,
/** If provided, the target's metadata cache will contain a link to this resolved path. */
existingBacklinkToHex = false,
) {
const hexFile = Object.create(TFile.prototype) as TFile;
hexFile.path = hexPath;
const targetFile = Object.create(TFile.prototype) as TFile;
targetFile.path = targetPath;
const contents: Record<string, string> = {
[hexPath]: hexContent,
[targetPath]: targetContent,
};
const app = {
vault: {
getAbstractFileByPath: (p: string) => {
if (p === hexPath) return hexFile;
if (p === targetPath) return targetFile;
return null;
},
read: mock.fn(async (f: TFile) => contents[f.path] ?? ""),
process: mock.fn(async (f: TFile, fn: (s: string) => string) => { contents[f.path] = fn(contents[f.path] ?? ""); return contents[f.path]; }),
},
metadataCache: {
getFileCache: mock.fn((f: TFile) => {
if (f === targetFile && existingBacklinkToHex) {
return { links: [{ link: hexPath }] };
}
return null;
}),
getFirstLinkpathDest: mock.fn((_link: string, _src: string) =>
existingBacklinkToHex ? hexFile : null,
),
fileToLinktext: mock.fn((f: TFile, _src: string) => f.path.replace(/\.md$/, "")),
},
} as unknown as import("obsidian").App;
return { app, getContent: (path: string) => contents[path] };
}
describe("addBacklinkToFile", () => {
it("is a no-op when hexFile does not exist", async () => {
const { app, getContent } = makeAppForBacklink("hex/1_1.md", "", "notes/town.md", "Town content.");
await addBacklinkToFile(app, "notes/town.md", "MISSING.md");
expect(getContent("notes/town.md")).toBe("Town content.");
});
it("is a no-op when targetFile does not exist", async () => {
const { app } = makeAppForBacklink("hex/1_1.md", "", "notes/town.md", "Town content.");
await expect(addBacklinkToFile(app, "MISSING.md", "hex/1_1.md")).resolves.toBeUndefined();
});
it("appends a wiki-link to the target file", async () => {
const { app, getContent } = makeAppForBacklink("hex/1_1.md", "", "notes/town.md", "Town content.");
await addBacklinkToFile(app, "notes/town.md", "hex/1_1.md");
expect(getContent("notes/town.md")).toContain("[[hex/1_1]]");
});
it("separates the link from existing content with a blank line", async () => {
const { app, getContent } = makeAppForBacklink("hex/1_1.md", "", "notes/town.md", "Town content.");
await addBacklinkToFile(app, "notes/town.md", "hex/1_1.md");
expect(getContent("notes/town.md")).toContain("Town content.\n\n[[hex/1_1]]");
});
it("does not add a blank line prefix when target is empty", async () => {
const { app, getContent } = makeAppForBacklink("hex/1_1.md", "", "notes/town.md", "");
await addBacklinkToFile(app, "notes/town.md", "hex/1_1.md");
expect(getContent("notes/town.md")).toBe("[[hex/1_1]]\n");
});
it("does not append when the target already links to the hex (via cache)", async () => {
const { app, getContent } = makeAppForBacklink(
"hex/1_1.md", "", "notes/town.md", "[[hex/1_1]]\n",
true, // existingBacklinkToHex
);
await addBacklinkToFile(app, "notes/town.md", "hex/1_1.md");
// process should never have been called
assert.strictEqual(app.vault.process.mock.callCount(), 0, "process should not have been called");
});
});
import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";
import expect from "expect";
import { TFile } from "obsidian";
import {
addLinkToSection,
removeLinkFromSection,
getLinksInSection,
getSectionContent,
getAllSectionData,
setSectionContent,
addBacklinkToFile,
} from "../src/sections";
/** Build a minimal mock App backed by an in-memory string. */
function makeApp(filePath: string, initialContent: string) {
let stored = initialContent;
const file = Object.create(TFile.prototype) as TFile;
file.path = filePath;
const app = {
vault: {
getAbstractFileByPath: (p: string) => (p === filePath ? file : null),
read: mock.fn(async () => stored),
process: mock.fn(async (_f: unknown, fn: (s: string) => string) => { stored = fn(stored); return stored; }),
},
metadataCache: {
getFileCache: mock.fn(() => null),
getFirstLinkpathDest: mock.fn(() => null),
fileToLinktext: mock.fn((f: TFile) => f.path),
},
} as unknown as import("obsidian").App;
return { app, getContent: () => stored };
}
// ── addLinkToSection ──────────────────────────────────────────────────────────
describe("addLinkToSection", () => {
it("appends a link under an existing section", async () => {
const { app, getContent } = makeApp("hex.md", "### Towns\n\n[[Riverdale]]\n");
await addLinkToSection(app, "hex.md", "Towns", "[[Millhaven]]");
expect(getContent()).toContain("[[Millhaven]]");
expect(getContent()).toContain("[[Riverdale]]");
});
it("creates the section when it does not exist", async () => {
const { app, getContent } = makeApp("hex.md", "Some content.");
await addLinkToSection(app, "hex.md", "Towns", "[[Newtown]]");
expect(getContent()).toContain("### Towns");
expect(getContent()).toContain("[[Newtown]]");
});
it("does not add a duplicate link", async () => {
const { app, getContent } = makeApp("hex.md", "### Towns\n\n[[Riverdale]]\n");
await addLinkToSection(app, "hex.md", "Towns", "[[Riverdale]]");
const count = (getContent().match(/\[\[Riverdale\]\]/g) ?? []).length;
expect(count).toBe(1);
});
it("does not modify other sections", async () => {
const { app, getContent } = makeApp("hex.md", "### Dungeons\n\n[[Cave]]\n\n### Towns\n\n");
await addLinkToSection(app, "hex.md", "Towns", "[[Village]]");
expect(getContent()).toContain("[[Cave]]");
});
it("is a no-op when file does not exist", async () => {
const { app } = makeApp("hex.md", "");
// Should not throw
await expect(addLinkToSection(app, "MISSING.md", "Towns", "[[X]]")).resolves.toBeUndefined();
});
it("inserts link before the --- separator, not after it", async () => {
// Template structure: ### Towns\n\n---\n\n### Dungeons
const { app, getContent } = makeApp("hex.md", "### Towns\n\n---\n\n### Dungeons\n\n");
await addLinkToSection(app, "hex.md", "Towns", "[[Millhaven]]");
const content = getContent();
const townsIdx = content.indexOf("### Towns");
const linkIdx = content.indexOf("[[Millhaven]]");
const hrIdx = content.indexOf("---");
const dungeonsIdx = content.indexOf("### Dungeons");
// Link must appear between the heading and the --- separator
expect(linkIdx).toBeGreaterThan(townsIdx);
expect(linkIdx).toBeLessThan(hrIdx);
// --- and ### Dungeons must remain after the link
expect(hrIdx).toBeLessThan(dungeonsIdx);
});
it("inserts second link before the --- separator when section already has a link", async () => {
const { app, getContent } = makeApp(
"hex.md",
"### Towns\n\n[[Riverdale]]\n\n---\n\n### Dungeons\n\n",
);
await addLinkToSection(app, "hex.md", "Towns", "[[Millhaven]]");
const content = getContent();
const hrIdx = content.indexOf("---");
const millIdx = content.indexOf("[[Millhaven]]");
const riverIdx = content.indexOf("[[Riverdale]]");
expect(riverIdx).toBeLessThan(hrIdx);
expect(millIdx).toBeLessThan(hrIdx);
});
});
// ── removeLinkFromSection ─────────────────────────────────────────────────────
describe("removeLinkFromSection", () => {
it("removes an existing link from a section", async () => {
const { app, getContent } = makeApp("hex.md", "### Towns\n\n[[Riverdale]]\n[[Millhaven]]\n");
await removeLinkFromSection(app, "hex.md", "Towns", "Riverdale");
expect(getContent()).not.toContain("[[Riverdale]]");
expect(getContent()).toContain("[[Millhaven]]");
});
it("is a no-op when the link is not present", async () => {
const original = "### Towns\n\n[[Millhaven]]\n";
const { app, getContent } = makeApp("hex.md", original);
await removeLinkFromSection(app, "hex.md", "Towns", "Missing");
expect(getContent()).toBe(original);
});
it("is a no-op when the section does not exist", async () => {
const original = "### Dungeons\n\n[[Cave]]\n";
const { app, getContent } = makeApp("hex.md", original);
await removeLinkFromSection(app, "hex.md", "Towns", "Cave");
expect(getContent()).toBe(original);
});
it("does not remove a link from a different section", async () => {
const { app, getContent } = makeApp("hex.md", "### Towns\n\n[[Village]]\n\n### Dungeons\n\n[[Village]]\n");
await removeLinkFromSection(app, "hex.md", "Towns", "Village");
// Link in Dungeons should survive
expect(getContent()).toContain("### Dungeons");
const dungeonSection = getContent().split("### Dungeons")[1];
expect(dungeonSection).toContain("[[Village]]");
});
});
// ── getLinksInSection ─────────────────────────────────────────────────────────
describe("getLinksInSection", () => {
it("returns all wiki-links in the section", async () => {
const { app } = makeApp("hex.md", "### Towns\n\n[[Riverdale]]\n[[Millhaven]]\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual(["Riverdale", "Millhaven"]);
});
it("returns empty array when section has no links", async () => {
const { app } = makeApp("hex.md", "### Towns\n\nJust text, no links.\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual([]);
});
it("returns empty array when section does not exist", async () => {
const { app } = makeApp("hex.md", "### Dungeons\n\n[[Cave]]\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual([]);
});
it("returns empty array when file does not exist", async () => {
const { app } = makeApp("hex.md", "");
const links = await getLinksInSection(app, "MISSING.md", "Towns");
expect(links).toEqual([]);
});
it("handles links with display text (pipe syntax)", async () => {
const { app } = makeApp("hex.md", "### Towns\n\n[[path/to/Town|Town Name]]\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual(["path/to/Town"]);
});
it("stops at the next heading", async () => {
const { app } = makeApp("hex.md", "### Towns\n\n[[A]]\n\n### Dungeons\n\n[[B]]\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual(["A"]);
});
it("stops at a horizontal rule (--- separator)", async () => {
// Matches default hex template structure where sections are separated by ---
const { app } = makeApp("hex.md", "### Towns\n\n[[A]]\n\n---\n\n### Dungeons\n\n[[B]]\n");
const links = await getLinksInSection(app, "hex.md", "Towns");
expect(links).toEqual(["A"]);
});
});
// ── getSectionContent ─────────────────────────────────────────────────────────
describe("getSectionContent", () => {
it("returns the trimmed body of a section", async () => {
const { app } = makeApp("hex.md", "### Description\n\nA misty valley.\n");
const content = await getSectionContent(app, "hex.md", "Description");
expect(content).toBe("A misty valley.");
});
it("returns empty string when section does not exist", async () => {
const { app } = makeApp("hex.md", "### Other\n\nSomething\n");
const content = await getSectionContent(app, "hex.md", "Description");
expect(content).toBe("");
});
it("returns empty string when file does not exist", async () => {
const { app } = makeApp("hex.md", "");
const content = await getSectionContent(app, "MISSING.md", "Description");
expect(content).toBe("");
});
it("stops at the next heading", async () => {
const { app } = makeApp("hex.md", "### Description\n\nLine one.\n\n### Notes\n\nLine two.\n");
const content = await getSectionContent(app, "hex.md", "Description");
expect(content).toBe("Line one.");
expect(content).not.toContain("Line two");
});
it("stops at a horizontal rule", async () => {
const { app } = makeApp("hex.md", "### Description\n\nBefore rule.\n\n---\n\nAfter rule.\n");
const content = await getSectionContent(app, "hex.md", "Description");
expect(content).toBe("Before rule.");
});
});
// ── getAllSectionData ─────────────────────────────────────────────────────────
describe("getAllSectionData", () => {
it("returns empty maps for a file with no sections", async () => {
const { app } = makeApp("hex.md", "Just prose, no headings.");
const { text, links } = await getAllSectionData(app, "hex.md");
expect(text.size).toBe(0);
expect(links.size).toBe(0);
});
it("returns empty maps when file does not exist", async () => {
const { app } = makeApp("hex.md", "");
const { text, links } = await getAllSectionData(app, "MISSING.md");
expect(text.size).toBe(0);
expect(links.size).toBe(0);
});
it("captures text and links from multiple sections", async () => {
const content = [
"### Description",
"",
"Foggy mountains.",
"",
"### Towns",
"",
"[[Riverdale]]",
"[[Millhaven]]",
].join("\n");
const { app } = makeApp("hex.md", content);
const { text, links } = await getAllSectionData(app, "hex.md");
expect(text.get("description")).toBe("Foggy mountains.");
expect(links.get("towns")).toEqual(["Riverdale", "Millhaven"]);
});
it("uses lowercase keys for section names", async () => {
const { app } = makeApp("hex.md", "### My Section\n\nHello.\n");
const { text } = await getAllSectionData(app, "hex.md");
expect(text.has("my section")).toBe(true);
});
});
// ── setSectionContent ─────────────────────────────────────────────────────────
describe("setSectionContent", () => {
it("replaces the body of an existing section", async () => {
const { app, getContent } = makeApp("hex.md", "### Description\n\nOld text.\n");
await setSectionContent(app, "hex.md", "Description", "New text.");
expect(getContent()).toContain("New text.");
expect(getContent()).not.toContain("Old text.");
});
it("creates the section when it does not exist", async () => {
const { app, getContent } = makeApp("hex.md", "Some content.");
await setSectionContent(app, "hex.md", "Notes", "My note.");
expect(getContent()).toContain("### Notes");
expect(getContent()).toContain("My note.");
});
it("clears section body when new text is empty", async () => {
const { app, getContent } = makeApp("hex.md", "### Description\n\nOld text.\n");
await setSectionContent(app, "hex.md", "Description", "");
expect(getContent()).not.toContain("Old text.");
});
it("does not create a section for empty new text", async () => {
const original = "No sections here.";
const { app, getContent } = makeApp("hex.md", original);
await setSectionContent(app, "hex.md", "Notes", "");
expect(getContent()).toBe(original);
});
it("does not affect adjacent sections", async () => {
const { app, getContent } = makeApp("hex.md",
"### Description\n\nOld.\n\n### Towns\n\n[[A]]\n",
);
await setSectionContent(app, "hex.md", "Description", "Updated.");
expect(getContent()).toContain("### Towns");
expect(getContent()).toContain("[[A]]");
});
it("is a no-op when file does not exist", async () => {
const { app } = makeApp("hex.md", "");
await expect(setSectionContent(app, "MISSING.md", "Description", "text")).resolves.toBeUndefined();
});
});
// ── addBacklinkToFile ─────────────────────────────────────────────────────────
/** Build an app with two independent in-memory files for backlink tests. */
function makeAppForBacklink(
hexPath: string,
hexContent: string,
targetPath: string,
targetContent: string,
/** If provided, the target's metadata cache will contain a link to this resolved path. */
existingBacklinkToHex = false,
) {
const hexFile = Object.create(TFile.prototype) as TFile;
hexFile.path = hexPath;
const targetFile = Object.create(TFile.prototype) as TFile;
targetFile.path = targetPath;
const contents: Record<string, string> = {
[hexPath]: hexContent,
[targetPath]: targetContent,
};
const app = {
vault: {
getAbstractFileByPath: (p: string) => {
if (p === hexPath) return hexFile;
if (p === targetPath) return targetFile;
return null;
},
read: mock.fn(async (f: TFile) => contents[f.path] ?? ""),
process: mock.fn(async (f: TFile, fn: (s: string) => string) => { contents[f.path] = fn(contents[f.path] ?? ""); return contents[f.path]; }),
},
metadataCache: {
getFileCache: mock.fn((f: TFile) => {
if (f === targetFile && existingBacklinkToHex) {
return { links: [{ link: hexPath }] };
}
return null;
}),
getFirstLinkpathDest: mock.fn((_link: string, _src: string) =>
existingBacklinkToHex ? hexFile : null,
),
fileToLinktext: mock.fn((f: TFile, _src: string) => f.path.replace(/\.md$/, "")),
},
} as unknown as import("obsidian").App;
return { app, getContent: (path: string) => contents[path] };
}
describe("addBacklinkToFile", () => {
it("is a no-op when hexFile does not exist", async () => {
const { app, getContent } = makeAppForBacklink("hex/1_1.md", "", "notes/town.md", "Town content.");
await addBacklinkToFile(app, "notes/town.md", "MISSING.md");
expect(getContent("notes/town.md")).toBe("Town content.");
});
it("is a no-op when targetFile does not exist", async () => {
const { app } = makeAppForBacklink("hex/1_1.md", "", "notes/town.md", "Town content.");
await expect(addBacklinkToFile(app, "MISSING.md", "hex/1_1.md")).resolves.toBeUndefined();
});
it("appends a wiki-link to the target file", async () => {
const { app, getContent } = makeAppForBacklink("hex/1_1.md", "", "notes/town.md", "Town content.");
await addBacklinkToFile(app, "notes/town.md", "hex/1_1.md");
expect(getContent("notes/town.md")).toContain("[[hex/1_1]]");
});
it("separates the link from existing content with a blank line", async () => {
const { app, getContent } = makeAppForBacklink("hex/1_1.md", "", "notes/town.md", "Town content.");
await addBacklinkToFile(app, "notes/town.md", "hex/1_1.md");
expect(getContent("notes/town.md")).toContain("Town content.\n\n[[hex/1_1]]");
});
it("does not add a blank line prefix when target is empty", async () => {
const { app, getContent } = makeAppForBacklink("hex/1_1.md", "", "notes/town.md", "");
await addBacklinkToFile(app, "notes/town.md", "hex/1_1.md");
expect(getContent("notes/town.md")).toBe("[[hex/1_1]]\n");
});
it("does not append when the target already links to the hex (via cache)", async () => {
const { app, getContent } = makeAppForBacklink(
"hex/1_1.md", "", "notes/town.md", "[[hex/1_1]]\n",
true, // existingBacklinkToHex
);
await addBacklinkToFile(app, "notes/town.md", "hex/1_1.md");
// process should never have been called
assert.strictEqual(app.vault.process.mock.callCount(), 0, "process should not have been called");
});
});

View file

@ -1,184 +1,184 @@
import { describe, it, mock } from "node:test";
import expect from "expect";
import { TFile } from "obsidian";
import { normalizeFolder, makeTableTemplate, getIconUrl } from "../src/utils";
import type HexmakerPlugin from "../src/HexmakerPlugin";
// ── normalizeFolder ───────────────────────────────────────────────────────────
describe("normalizeFolder", () => {
it("returns empty string for empty input", () => {
expect(normalizeFolder("")).toBe("");
});
it("strips a leading slash", () => {
expect(normalizeFolder("/tables")).toBe("tables");
});
it("strips a trailing slash", () => {
expect(normalizeFolder("tables/")).toBe("tables");
});
it("strips both leading and trailing slashes", () => {
expect(normalizeFolder("/tables/")).toBe("tables");
});
it("strips multiple leading and trailing slashes", () => {
expect(normalizeFolder("///tables///")).toBe("tables");
});
it("leaves interior slashes intact", () => {
expect(normalizeFolder("world/tables")).toBe("world/tables");
});
it("returns empty string for a string that is only slashes", () => {
expect(normalizeFolder("///")).toBe("");
});
it("does not modify a clean path", () => {
expect(normalizeFolder("tables/terrain")).toBe("tables/terrain");
});
});
// ── makeTableTemplate ─────────────────────────────────────────────────────────
describe("makeTableTemplate", () => {
it("includes the dice value in frontmatter", () => {
const t = makeTableTemplate(6);
expect(t).toContain("dice: 6");
});
it("produces valid YAML frontmatter block", () => {
const t = makeTableTemplate(4);
expect(t).toMatch(/^---\n/);
expect(t).toContain("\n---\n");
});
it("generates a single blank example row", () => {
const t = makeTableTemplate(6);
expect(t).toContain("| | 1 |");
});
it("includes the Result/Weight table header", () => {
const t = makeTableTemplate(6);
expect(t).toContain("| Result | Weight |");
expect(t).toContain("|--------|--------|");
});
it("dice: 0 still produces valid frontmatter", () => {
const t = makeTableTemplate(0);
expect(t).toContain("dice: 0");
});
it("includes extra frontmatter fields when provided", () => {
const t = makeTableTemplate(6, { terrain: "forest", category: "monsters" });
expect(t).toContain("terrain: forest");
expect(t).toContain("category: monsters");
});
it("serialises boolean extra frontmatter values", () => {
const t = makeTableTemplate(6, { "roll-filter": false });
expect(t).toContain("roll-filter: false");
});
it("serialises number extra frontmatter values", () => {
const t = makeTableTemplate(6, { level: 3 });
expect(t).toContain("level: 3");
});
it("includes preamble between frontmatter and table", () => {
const t = makeTableTemplate(6, undefined, "[🎲 Open](obsidian://roll)");
expect(t).toContain("[🎲 Open](obsidian://roll)");
const preambleIdx = t.indexOf("[🎲 Open]");
const tableIdx = t.indexOf("| Result |");
expect(preambleIdx).toBeLessThan(tableIdx);
});
});
// ── getIconUrl ────────────────────────────────────────────────────────────────
/** Build a minimal plugin stub for getIconUrl. */
function makePluginForIcon(
vaultIcons: string[],
iconsFolder: string,
manifestDir: string,
): { plugin: HexmakerPlugin; getLastPath: () => string } {
let lastPath = "";
const vaultIconSet = new Set(vaultIcons);
const plugin = {
vaultIconsSet: vaultIconSet,
settings: { iconsFolder },
manifest: { dir: manifestDir },
app: {
vault: {
adapter: {
getResourcePath: mock.fn((p: string) => { lastPath = p; return `resource://${p}`; }),
},
getAbstractFileByPath: mock.fn((path: string) => {
const filename = path.split("/").pop() ?? "";
if (vaultIconSet.has(filename)) {
const f = Object.create(TFile.prototype) as TFile;
f.path = path;
return f;
}
return null;
}),
getResourcePath: mock.fn((f: TFile) => { lastPath = f.path; return `resource://${f.path}`; }),
},
},
} as unknown as HexmakerPlugin;
return { plugin, getLastPath: () => lastPath };
}
describe("getIconUrl", () => {
it("uses plugin icons dir when icon is not in vaultIconsSet", () => {
const { plugin, getLastPath } = makePluginForIcon(
[],
"custom",
"plugins/duckmage-plugin",
);
getIconUrl(plugin, "tower.png");
expect(getLastPath()).toBe("plugins/duckmage-plugin/icons/tower.png");
});
it("uses vault iconsFolder when icon is in vaultIconsSet", () => {
const { plugin, getLastPath } = makePluginForIcon(
["village.png"],
"custom-icons",
"plugins/duckmage-plugin",
);
getIconUrl(plugin, "village.png");
expect(getLastPath()).toBe("custom-icons/village.png");
});
it("uses plugin icons dir for icons not in vaultIconsSet even when others are", () => {
const { plugin, getLastPath } = makePluginForIcon(
["village.png"],
"custom-icons",
"plugins/duckmage-plugin",
);
getIconUrl(plugin, "castle.png");
expect(getLastPath()).toBe("plugins/duckmage-plugin/icons/castle.png");
});
it("normalises iconsFolder by stripping leading/trailing slashes", () => {
const { plugin, getLastPath } = makePluginForIcon(
["icon.png"],
"/my-icons/",
"plugins/duckmage-plugin",
);
getIconUrl(plugin, "icon.png");
expect(getLastPath()).toBe("my-icons/icon.png");
});
it("returns a string (the resource path)", () => {
const { plugin } = makePluginForIcon(
[],
"icons",
"plugins/duckmage-plugin",
);
const result = getIconUrl(plugin, "ruins.png");
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
});
import { describe, it, mock } from "node:test";
import expect from "expect";
import { TFile } from "obsidian";
import { normalizeFolder, makeTableTemplate, getIconUrl } from "../src/utils";
import type HexmakerPlugin from "../src/HexmakerPlugin";
// ── normalizeFolder ───────────────────────────────────────────────────────────
describe("normalizeFolder", () => {
it("returns empty string for empty input", () => {
expect(normalizeFolder("")).toBe("");
});
it("strips a leading slash", () => {
expect(normalizeFolder("/tables")).toBe("tables");
});
it("strips a trailing slash", () => {
expect(normalizeFolder("tables/")).toBe("tables");
});
it("strips both leading and trailing slashes", () => {
expect(normalizeFolder("/tables/")).toBe("tables");
});
it("strips multiple leading and trailing slashes", () => {
expect(normalizeFolder("///tables///")).toBe("tables");
});
it("leaves interior slashes intact", () => {
expect(normalizeFolder("world/tables")).toBe("world/tables");
});
it("returns empty string for a string that is only slashes", () => {
expect(normalizeFolder("///")).toBe("");
});
it("does not modify a clean path", () => {
expect(normalizeFolder("tables/terrain")).toBe("tables/terrain");
});
});
// ── makeTableTemplate ─────────────────────────────────────────────────────────
describe("makeTableTemplate", () => {
it("includes the dice value in frontmatter", () => {
const t = makeTableTemplate(6);
expect(t).toContain("dice: 6");
});
it("produces valid YAML frontmatter block", () => {
const t = makeTableTemplate(4);
expect(t).toMatch(/^---\n/);
expect(t).toContain("\n---\n");
});
it("generates a single blank example row", () => {
const t = makeTableTemplate(6);
expect(t).toContain("| | 1 |");
});
it("includes the Result/Weight table header", () => {
const t = makeTableTemplate(6);
expect(t).toContain("| Result | Weight |");
expect(t).toContain("|--------|--------|");
});
it("dice: 0 still produces valid frontmatter", () => {
const t = makeTableTemplate(0);
expect(t).toContain("dice: 0");
});
it("includes extra frontmatter fields when provided", () => {
const t = makeTableTemplate(6, { terrain: "forest", category: "monsters" });
expect(t).toContain("terrain: forest");
expect(t).toContain("category: monsters");
});
it("serialises boolean extra frontmatter values", () => {
const t = makeTableTemplate(6, { "roll-filter": false });
expect(t).toContain("roll-filter: false");
});
it("serialises number extra frontmatter values", () => {
const t = makeTableTemplate(6, { level: 3 });
expect(t).toContain("level: 3");
});
it("includes preamble between frontmatter and table", () => {
const t = makeTableTemplate(6, undefined, "[🎲 Open](obsidian://roll)");
expect(t).toContain("[🎲 Open](obsidian://roll)");
const preambleIdx = t.indexOf("[🎲 Open]");
const tableIdx = t.indexOf("| Result |");
expect(preambleIdx).toBeLessThan(tableIdx);
});
});
// ── getIconUrl ────────────────────────────────────────────────────────────────
/** Build a minimal plugin stub for getIconUrl. */
function makePluginForIcon(
vaultIcons: string[],
iconsFolder: string,
manifestDir: string,
): { plugin: HexmakerPlugin; getLastPath: () => string } {
let lastPath = "";
const vaultIconSet = new Set(vaultIcons);
const plugin = {
vaultIconsSet: vaultIconSet,
settings: { iconsFolder },
manifest: { dir: manifestDir },
app: {
vault: {
adapter: {
getResourcePath: mock.fn((p: string) => { lastPath = p; return `resource://${p}`; }),
},
getAbstractFileByPath: mock.fn((path: string) => {
const filename = path.split("/").pop() ?? "";
if (vaultIconSet.has(filename)) {
const f = Object.create(TFile.prototype) as TFile;
f.path = path;
return f;
}
return null;
}),
getResourcePath: mock.fn((f: TFile) => { lastPath = f.path; return `resource://${f.path}`; }),
},
},
} as unknown as HexmakerPlugin;
return { plugin, getLastPath: () => lastPath };
}
describe("getIconUrl", () => {
it("uses plugin icons dir when icon is not in vaultIconsSet", () => {
const { plugin, getLastPath } = makePluginForIcon(
[],
"custom",
"plugins/duckmage-plugin",
);
getIconUrl(plugin, "tower.png");
expect(getLastPath()).toBe("plugins/duckmage-plugin/icons/tower.png");
});
it("uses vault iconsFolder when icon is in vaultIconsSet", () => {
const { plugin, getLastPath } = makePluginForIcon(
["village.png"],
"custom-icons",
"plugins/duckmage-plugin",
);
getIconUrl(plugin, "village.png");
expect(getLastPath()).toBe("custom-icons/village.png");
});
it("uses plugin icons dir for icons not in vaultIconsSet even when others are", () => {
const { plugin, getLastPath } = makePluginForIcon(
["village.png"],
"custom-icons",
"plugins/duckmage-plugin",
);
getIconUrl(plugin, "castle.png");
expect(getLastPath()).toBe("plugins/duckmage-plugin/icons/castle.png");
});
it("normalises iconsFolder by stripping leading/trailing slashes", () => {
const { plugin, getLastPath } = makePluginForIcon(
["icon.png"],
"/my-icons/",
"plugins/duckmage-plugin",
);
getIconUrl(plugin, "icon.png");
expect(getLastPath()).toBe("my-icons/icon.png");
});
it("returns a string (the resource path)", () => {
const { plugin } = makePluginForIcon(
[],
"icons",
"plugins/duckmage-plugin",
);
const result = getIconUrl(plugin, "ruins.png");
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
});

View file

@ -1,495 +1,495 @@
import { describe, it, afterEach, mock } from "node:test";
import expect from "expect";
import {
isDiceFormula,
rollDiceFormula,
rollDiceFormulaWithBreakdown,
parseWorkflow,
buildWorkflowContent,
stepVarName,
stepPlaceholder,
generateDefaultTemplate,
requiredPlaceholders,
type WorkflowStep,
type Workflow,
} from "../src/random-tables/workflow";
// ── isDiceFormula ─────────────────────────────────────────────────────────────
describe("isDiceFormula", () => {
it("accepts basic XdY format", () => {
expect(isDiceFormula("2d6")).toBe(true);
expect(isDiceFormula("1d20")).toBe(true);
expect(isDiceFormula("4d8")).toBe(true);
});
it("accepts dY format (omitted die count)", () => {
expect(isDiceFormula("d6")).toBe(true);
expect(isDiceFormula("d20")).toBe(true);
});
it("accepts positive modifier", () => {
expect(isDiceFormula("2d6+6")).toBe(true);
expect(isDiceFormula("1d4+1")).toBe(true);
});
it("accepts negative modifier", () => {
expect(isDiceFormula("2d6-3")).toBe(true);
expect(isDiceFormula("d20-5")).toBe(true);
});
it("is case-insensitive (D vs d)", () => {
expect(isDiceFormula("2D6")).toBe(true);
expect(isDiceFormula("1D20+3")).toBe(true);
});
it("trims whitespace before checking", () => {
expect(isDiceFormula(" 2d6 ")).toBe(true);
});
it("rejects plain numbers", () => {
expect(isDiceFormula("6")).toBe(false);
expect(isDiceFormula("20")).toBe(false);
});
it("rejects table paths", () => {
expect(isDiceFormula("world/tables/encounters")).toBe(false);
expect(isDiceFormula("[[some/table]]")).toBe(false);
});
it("rejects malformed formulas", () => {
expect(isDiceFormula("2d")).toBe(false); // no die size
expect(isDiceFormula("d")).toBe(false); // no die size
expect(isDiceFormula("2d6d8")).toBe(false); // double d
expect(isDiceFormula("abc")).toBe(false);
expect(isDiceFormula("")).toBe(false);
});
it("rejects formulas with non-numeric modifiers", () => {
expect(isDiceFormula("2d6+x")).toBe(false);
});
});
// ── rollDiceFormula ───────────────────────────────────────────────────────────
describe("rollDiceFormula", () => {
afterEach(() => mock.restoreAll());
it("returns 0 for invalid formula", () => {
expect(rollDiceFormula("invalid")).toBe(0);
expect(rollDiceFormula("")).toBe(0);
});
it("rolls a single die with mocked random", () => {
mock.method(Math, "random", () => 0); // floor(0 * 6) + 1 = 1
expect(rollDiceFormula("1d6")).toBe(1);
});
it("applies positive modifier", () => {
mock.method(Math, "random", () => 0); // 1 + 6 = 7
expect(rollDiceFormula("1d6+6")).toBe(7);
});
it("applies negative modifier", () => {
mock.method(Math, "random", () => 0.999); // floor(0.999 * 6) + 1 = 6, 6-3 = 3
expect(rollDiceFormula("1d6-3")).toBe(3);
});
it("sums multiple dice", () => {
mock.method(Math, "random", () => 0); // each die = 1, 3 dice = 3
expect(rollDiceFormula("3d6")).toBe(3);
});
it("omitted die count defaults to 1", () => {
mock.method(Math, "random", () => 0.5); // floor(0.5 * 20) + 1 = 11
expect(rollDiceFormula("d20")).toBe(11);
});
it("is case-insensitive", () => {
mock.method(Math, "random", () => 0);
expect(rollDiceFormula("1D6")).toBe(1);
});
it("result is always within valid range for 1d6", () => {
mock.restoreAll();
for (let i = 0; i < 100; i++) {
const r = rollDiceFormula("1d6");
expect(r).toBeGreaterThanOrEqual(1);
expect(r).toBeLessThanOrEqual(6);
}
});
});
// ── rollDiceFormulaWithBreakdown ──────────────────────────────────────────────
describe("rollDiceFormulaWithBreakdown", () => {
afterEach(() => mock.restoreAll());
it("returns '0' for invalid formula", () => {
expect(rollDiceFormulaWithBreakdown("invalid")).toBe("0");
});
it("returns plain number for single die with no modifier", () => {
mock.method(Math, "random", () => 0); // 1
expect(rollDiceFormulaWithBreakdown("1d6")).toBe("1");
});
it("returns plain number for omitted-count single die with no modifier", () => {
mock.method(Math, "random", () => 0.5); // floor(0.5*20)+1 = 11
expect(rollDiceFormulaWithBreakdown("d20")).toBe("11");
});
it("returns breakdown for multiple dice", () => {
mock.method(Math, "random", () => 0); // each die = 1
expect(rollDiceFormulaWithBreakdown("2d6")).toBe("(1+1)=2");
});
it("includes positive modifier in expression", () => {
mock.method(Math, "random", () => 0); // die = 1, total = 1+6 = 7
expect(rollDiceFormulaWithBreakdown("1d6+6")).toBe("(1+6)=7");
});
it("includes negative modifier in expression without double sign", () => {
mock.method(Math, "random", () => 0.999); // floor(0.999*6)+1 = 6, 6-3=3
expect(rollDiceFormulaWithBreakdown("1d6-3")).toBe("(6-3)=3");
});
it("handles multiple dice with modifier", () => {
mock.method(Math, "random", () => 0); // each die = 1, total = 1+1+6=8
expect(rollDiceFormulaWithBreakdown("2d6+6")).toBe("(1+1+6)=8");
});
it("breakdown total matches numeric rollDiceFormula result", () => {
// Use a fixed random to compare both functions
const randomVal = 0.4;
mock.method(Math, "random", () => randomVal);
const numeric = rollDiceFormula("2d6+3");
mock.method(Math, "random", () => randomVal);
const breakdown = rollDiceFormulaWithBreakdown("2d6+3");
const extracted = parseInt(breakdown.replace(/.*=(-?\d+)$/, "$1"), 10);
expect(extracted).toBe(numeric);
});
});
// ── parseWorkflow ─────────────────────────────────────────────────────────────
const tableStep = (path: string, rolls = 1, label?: string): WorkflowStep => ({
kind: "table",
tablePath: path,
rolls,
label,
});
const diceStep = (formula: string, rolls = 1, label?: string): WorkflowStep => ({
kind: "dice",
tablePath: "",
diceFormula: formula,
rolls,
label,
});
describe("parseWorkflow", () => {
it("returns empty workflow for empty content", () => {
const wf = parseWorkflow("", "test");
expect(wf.name).toBe("test");
expect(wf.steps).toEqual([]);
expect(wf.resultsFolder).toBeUndefined();
expect(wf.templateFile).toBeUndefined();
});
it("parses results-folder from frontmatter", () => {
const content = `---\nresults-folder: world/results\n---\n\n| Table | Rolls | Label |\n|---|---|---|\n`;
const wf = parseWorkflow(content, "test");
expect(wf.resultsFolder).toBe("world/results");
});
it("parses template-file from frontmatter", () => {
const content = `---\ntemplate-file: world/workflows/templates/test.md\n---\n\n| Table | Rolls | Label |\n|---|---|---|\n`;
const wf = parseWorkflow(content, "test");
expect(wf.templateFile).toBe("world/workflows/templates/test.md");
});
it("parses both frontmatter fields", () => {
const content = `---\nresults-folder: world/results\ntemplate-file: world/workflows/templates/t.md\n---\n\n| Table | Rolls | Label |\n|---|---|---|\n`;
const wf = parseWorkflow(content, "test");
expect(wf.resultsFolder).toBe("world/results");
expect(wf.templateFile).toBe("world/workflows/templates/t.md");
});
it("returns no steps when no table header found", () => {
const content = `---\nresults-folder: x\n---\n\nSome text with no table.`;
expect(parseWorkflow(content, "test").steps).toEqual([]);
});
it("parses a single table step", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/encounters]] | 1 | Encounters |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps).toHaveLength(1);
expect(wf.steps[0]).toEqual(tableStep("world/tables/encounters", 1, "Encounters"));
});
it("strips [[wikilink]] syntax from table cell", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/my table]] | 1 | |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps[0].tablePath).toBe("world/tables/my table");
expect(wf.steps[0].kind).toBe("table");
});
it("parses a dice step", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| 2d6+6 | 1 | (2d6+6) |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps).toHaveLength(1);
expect(wf.steps[0]).toEqual(diceStep("2d6+6", 1, "(2d6+6)"));
});
it("parses dice step with no label", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| d20 | 1 | |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps[0].kind).toBe("dice");
expect(wf.steps[0].diceFormula).toBe("d20");
expect(wf.steps[0].label).toBeUndefined();
});
it("parses multiple rolls", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/enc]] | 3 | enc |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps[0].rolls).toBe(3);
});
it("parses multiple steps", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/a]] | 1 | A |\n| 2d6 | 2 | (2d6) |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps).toHaveLength(2);
expect(wf.steps[0].kind).toBe("table");
expect(wf.steps[1].kind).toBe("dice");
});
it("skips rows with missing or invalid roll count", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/a]] | abc | A |\n| [[world/tables/b]] | 0 | B |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps).toHaveLength(0);
});
it("empty label becomes undefined", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/enc]] | 1 | |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps[0].label).toBeUndefined();
});
it("is case-insensitive for table header", () => {
const content = `| TABLE | ROLLS | LABEL |\n|---|---|---|\n| [[world/tables/enc]] | 1 | enc |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps).toHaveLength(1);
});
});
// ── buildWorkflowContent ──────────────────────────────────────────────────────
describe("buildWorkflowContent", () => {
it("produces minimal valid output with no steps", () => {
const wf: Workflow = { name: "test", steps: [] };
const out = buildWorkflowContent(wf);
expect(out).toContain("---\n---");
expect(out).toContain("| Table | Rolls | Label |");
});
it("includes results-folder in frontmatter when set", () => {
const wf: Workflow = { name: "t", resultsFolder: "world/results", steps: [] };
expect(buildWorkflowContent(wf)).toContain("results-folder: world/results");
});
it("includes template-file in frontmatter when set", () => {
const wf: Workflow = { name: "t", templateFile: "world/workflows/templates/t.md", steps: [] };
expect(buildWorkflowContent(wf)).toContain("template-file: world/workflows/templates/t.md");
});
it("omits optional frontmatter fields when absent", () => {
const wf: Workflow = { name: "t", steps: [] };
const out = buildWorkflowContent(wf);
expect(out).not.toContain("results-folder");
expect(out).not.toContain("template-file");
});
it("serializes table step with wikilink", () => {
const wf: Workflow = { name: "t", steps: [tableStep("world/tables/enc", 1, "Encounters")] };
expect(buildWorkflowContent(wf)).toContain("| [[world/tables/enc]] | 1 | Encounters |");
});
it("serializes dice step without wikilink brackets", () => {
const wf: Workflow = { name: "t", steps: [diceStep("2d6+6", 1, "(2d6+6)")] };
expect(buildWorkflowContent(wf)).toContain("| 2d6+6 | 1 | (2d6+6) |");
});
it("serializes step with empty label as empty cell", () => {
const wf: Workflow = { name: "t", steps: [tableStep("world/tables/enc", 1, undefined)] };
expect(buildWorkflowContent(wf)).toContain("| [[world/tables/enc]] | 1 | |");
});
it("round-trips through parse → build → parse", () => {
const original: Workflow = {
name: "roundtrip",
resultsFolder: "world/results",
templateFile: "world/workflows/templates/roundtrip.md",
steps: [
tableStep("world/tables/enc", 2, "Encounters"),
diceStep("2d6", 1, "(2d6)"),
],
};
const serialized = buildWorkflowContent(original);
const parsed = parseWorkflow(serialized, "roundtrip");
expect(parsed.resultsFolder).toBe(original.resultsFolder);
expect(parsed.templateFile).toBe(original.templateFile);
expect(parsed.steps).toHaveLength(2);
expect(parsed.steps[0]).toEqual(original.steps[0]);
expect(parsed.steps[1]).toEqual(original.steps[1]);
});
});
// ── stepVarName ───────────────────────────────────────────────────────────────
describe("stepVarName", () => {
it("uses label when set, replacing spaces with underscores", () => {
const step: WorkflowStep = { kind: "table", tablePath: "world/tables/enc", rolls: 1, label: "My Table" };
expect(stepVarName(step)).toBe("My_Table");
});
it("uses table basename when no label", () => {
const step: WorkflowStep = { kind: "table", tablePath: "world/tables/encounters", rolls: 1 };
expect(stepVarName(step)).toBe("encounters");
});
it("replaces spaces in basename with underscores", () => {
const step: WorkflowStep = { kind: "table", tablePath: "world/tables/random encounters", rolls: 1 };
expect(stepVarName(step)).toBe("random_encounters");
});
it("uses formula in parens for dice step with no label", () => {
const step: WorkflowStep = { kind: "dice", tablePath: "", diceFormula: "2d6+6", rolls: 1 };
expect(stepVarName(step)).toBe("(2d6+6)");
});
it("falls back to 'dice' when dice step has no formula", () => {
const step: WorkflowStep = { kind: "dice", tablePath: "", rolls: 1 };
expect(stepVarName(step)).toBe("dice");
});
it("label takes priority over dice formula", () => {
const step: WorkflowStep = { kind: "dice", tablePath: "", diceFormula: "2d6", rolls: 1, label: "damage" };
expect(stepVarName(step)).toBe("damage");
});
it("uses full path basename (after last slash)", () => {
const step: WorkflowStep = { kind: "table", tablePath: "a/b/c/my table", rolls: 1 };
expect(stepVarName(step)).toBe("my_table");
});
});
// ── stepPlaceholder ───────────────────────────────────────────────────────────
describe("stepPlaceholder", () => {
const tableStepFixed: WorkflowStep = { kind: "table", tablePath: "world/tables/enc", rolls: 1, label: "Encounters" };
it("returns $label for single-roll step", () => {
expect(stepPlaceholder(tableStepFixed, 0)).toBe("$Encounters");
});
it("returns $label_N for multi-roll step", () => {
const multi: WorkflowStep = { ...tableStepFixed, rolls: 3 };
expect(stepPlaceholder(multi, 0)).toBe("$Encounters_1");
expect(stepPlaceholder(multi, 1)).toBe("$Encounters_2");
expect(stepPlaceholder(multi, 2)).toBe("$Encounters_3");
});
it("uses formula-in-parens for dice step placeholder", () => {
const step: WorkflowStep = { kind: "dice", tablePath: "", diceFormula: "2d6", rolls: 1 };
expect(stepPlaceholder(step, 0)).toBe("$(2d6)");
});
it("uses label for dice step when label is set", () => {
const step: WorkflowStep = { kind: "dice", tablePath: "", diceFormula: "2d6", rolls: 1, label: "damage" };
expect(stepPlaceholder(step, 0)).toBe("$damage");
});
});
// ── generateDefaultTemplate ───────────────────────────────────────────────────
describe("generateDefaultTemplate", () => {
it("returns empty string for no steps", () => {
expect(generateDefaultTemplate([])).toBe("");
});
it("generates heading and placeholder for a single-roll table step", () => {
const steps: WorkflowStep[] = [tableStep("world/tables/enc", 1, "Encounters")];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("## Encounters");
expect(tmpl).toContain("$Encounters");
});
it("generates multiple placeholders for multi-roll step", () => {
const steps: WorkflowStep[] = [tableStep("world/tables/enc", 3, "Encounters")];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("$Encounters_1");
expect(tmpl).toContain("$Encounters_2");
expect(tmpl).toContain("$Encounters_3");
expect(tmpl).not.toContain("$Encounters\n");
});
it("uses formula as heading when dice step has no label", () => {
const steps: WorkflowStep[] = [diceStep("2d6", 1)];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("## 2d6");
expect(tmpl).toContain("$(2d6)");
});
it("uses label as heading when set", () => {
const steps: WorkflowStep[] = [diceStep("2d6", 1, "damage")];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("## damage");
expect(tmpl).toContain("$damage");
});
it("generates a section per step", () => {
const steps: WorkflowStep[] = [
tableStep("world/tables/enc", 1, "Encounters"),
diceStep("2d6+6", 1, "(2d6+6)"),
];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("## Encounters");
expect(tmpl).toContain("## (2d6+6)");
});
it("uses 'Table N' heading for unlabelled table step", () => {
const steps: WorkflowStep[] = [tableStep("world/tables/enc", 1)];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("## Table 1");
});
});
// ── requiredPlaceholders ──────────────────────────────────────────────────────
describe("requiredPlaceholders", () => {
it("returns empty array for no steps", () => {
expect(requiredPlaceholders([])).toEqual([]);
});
it("returns one placeholder per single-roll step", () => {
const steps: WorkflowStep[] = [
tableStep("world/tables/enc", 1, "enc"),
diceStep("2d6", 1, "roll"),
];
expect(requiredPlaceholders(steps)).toEqual(["$enc", "$roll"]);
});
it("returns N placeholders for a multi-roll step", () => {
const steps: WorkflowStep[] = [tableStep("world/tables/enc", 3, "enc")];
expect(requiredPlaceholders(steps)).toEqual(["$enc_1", "$enc_2", "$enc_3"]);
});
it("accumulates placeholders across multiple steps", () => {
const steps: WorkflowStep[] = [
tableStep("world/tables/a", 2, "A"),
tableStep("world/tables/b", 1, "B"),
];
expect(requiredPlaceholders(steps)).toEqual(["$A_1", "$A_2", "$B"]);
});
});
import { describe, it, afterEach, mock } from "node:test";
import expect from "expect";
import {
isDiceFormula,
rollDiceFormula,
rollDiceFormulaWithBreakdown,
parseWorkflow,
buildWorkflowContent,
stepVarName,
stepPlaceholder,
generateDefaultTemplate,
requiredPlaceholders,
type WorkflowStep,
type Workflow,
} from "../src/random-tables/workflow";
// ── isDiceFormula ─────────────────────────────────────────────────────────────
describe("isDiceFormula", () => {
it("accepts basic XdY format", () => {
expect(isDiceFormula("2d6")).toBe(true);
expect(isDiceFormula("1d20")).toBe(true);
expect(isDiceFormula("4d8")).toBe(true);
});
it("accepts dY format (omitted die count)", () => {
expect(isDiceFormula("d6")).toBe(true);
expect(isDiceFormula("d20")).toBe(true);
});
it("accepts positive modifier", () => {
expect(isDiceFormula("2d6+6")).toBe(true);
expect(isDiceFormula("1d4+1")).toBe(true);
});
it("accepts negative modifier", () => {
expect(isDiceFormula("2d6-3")).toBe(true);
expect(isDiceFormula("d20-5")).toBe(true);
});
it("is case-insensitive (D vs d)", () => {
expect(isDiceFormula("2D6")).toBe(true);
expect(isDiceFormula("1D20+3")).toBe(true);
});
it("trims whitespace before checking", () => {
expect(isDiceFormula(" 2d6 ")).toBe(true);
});
it("rejects plain numbers", () => {
expect(isDiceFormula("6")).toBe(false);
expect(isDiceFormula("20")).toBe(false);
});
it("rejects table paths", () => {
expect(isDiceFormula("world/tables/encounters")).toBe(false);
expect(isDiceFormula("[[some/table]]")).toBe(false);
});
it("rejects malformed formulas", () => {
expect(isDiceFormula("2d")).toBe(false); // no die size
expect(isDiceFormula("d")).toBe(false); // no die size
expect(isDiceFormula("2d6d8")).toBe(false); // double d
expect(isDiceFormula("abc")).toBe(false);
expect(isDiceFormula("")).toBe(false);
});
it("rejects formulas with non-numeric modifiers", () => {
expect(isDiceFormula("2d6+x")).toBe(false);
});
});
// ── rollDiceFormula ───────────────────────────────────────────────────────────
describe("rollDiceFormula", () => {
afterEach(() => mock.restoreAll());
it("returns 0 for invalid formula", () => {
expect(rollDiceFormula("invalid")).toBe(0);
expect(rollDiceFormula("")).toBe(0);
});
it("rolls a single die with mocked random", () => {
mock.method(Math, "random", () => 0); // floor(0 * 6) + 1 = 1
expect(rollDiceFormula("1d6")).toBe(1);
});
it("applies positive modifier", () => {
mock.method(Math, "random", () => 0); // 1 + 6 = 7
expect(rollDiceFormula("1d6+6")).toBe(7);
});
it("applies negative modifier", () => {
mock.method(Math, "random", () => 0.999); // floor(0.999 * 6) + 1 = 6, 6-3 = 3
expect(rollDiceFormula("1d6-3")).toBe(3);
});
it("sums multiple dice", () => {
mock.method(Math, "random", () => 0); // each die = 1, 3 dice = 3
expect(rollDiceFormula("3d6")).toBe(3);
});
it("omitted die count defaults to 1", () => {
mock.method(Math, "random", () => 0.5); // floor(0.5 * 20) + 1 = 11
expect(rollDiceFormula("d20")).toBe(11);
});
it("is case-insensitive", () => {
mock.method(Math, "random", () => 0);
expect(rollDiceFormula("1D6")).toBe(1);
});
it("result is always within valid range for 1d6", () => {
mock.restoreAll();
for (let i = 0; i < 100; i++) {
const r = rollDiceFormula("1d6");
expect(r).toBeGreaterThanOrEqual(1);
expect(r).toBeLessThanOrEqual(6);
}
});
});
// ── rollDiceFormulaWithBreakdown ──────────────────────────────────────────────
describe("rollDiceFormulaWithBreakdown", () => {
afterEach(() => mock.restoreAll());
it("returns '0' for invalid formula", () => {
expect(rollDiceFormulaWithBreakdown("invalid")).toBe("0");
});
it("returns plain number for single die with no modifier", () => {
mock.method(Math, "random", () => 0); // 1
expect(rollDiceFormulaWithBreakdown("1d6")).toBe("1");
});
it("returns plain number for omitted-count single die with no modifier", () => {
mock.method(Math, "random", () => 0.5); // floor(0.5*20)+1 = 11
expect(rollDiceFormulaWithBreakdown("d20")).toBe("11");
});
it("returns breakdown for multiple dice", () => {
mock.method(Math, "random", () => 0); // each die = 1
expect(rollDiceFormulaWithBreakdown("2d6")).toBe("(1+1)=2");
});
it("includes positive modifier in expression", () => {
mock.method(Math, "random", () => 0); // die = 1, total = 1+6 = 7
expect(rollDiceFormulaWithBreakdown("1d6+6")).toBe("(1+6)=7");
});
it("includes negative modifier in expression without double sign", () => {
mock.method(Math, "random", () => 0.999); // floor(0.999*6)+1 = 6, 6-3=3
expect(rollDiceFormulaWithBreakdown("1d6-3")).toBe("(6-3)=3");
});
it("handles multiple dice with modifier", () => {
mock.method(Math, "random", () => 0); // each die = 1, total = 1+1+6=8
expect(rollDiceFormulaWithBreakdown("2d6+6")).toBe("(1+1+6)=8");
});
it("breakdown total matches numeric rollDiceFormula result", () => {
// Use a fixed random to compare both functions
const randomVal = 0.4;
mock.method(Math, "random", () => randomVal);
const numeric = rollDiceFormula("2d6+3");
mock.method(Math, "random", () => randomVal);
const breakdown = rollDiceFormulaWithBreakdown("2d6+3");
const extracted = parseInt(breakdown.replace(/.*=(-?\d+)$/, "$1"), 10);
expect(extracted).toBe(numeric);
});
});
// ── parseWorkflow ─────────────────────────────────────────────────────────────
const tableStep = (path: string, rolls = 1, label?: string): WorkflowStep => ({
kind: "table",
tablePath: path,
rolls,
label,
});
const diceStep = (formula: string, rolls = 1, label?: string): WorkflowStep => ({
kind: "dice",
tablePath: "",
diceFormula: formula,
rolls,
label,
});
describe("parseWorkflow", () => {
it("returns empty workflow for empty content", () => {
const wf = parseWorkflow("", "test");
expect(wf.name).toBe("test");
expect(wf.steps).toEqual([]);
expect(wf.resultsFolder).toBeUndefined();
expect(wf.templateFile).toBeUndefined();
});
it("parses results-folder from frontmatter", () => {
const content = `---\nresults-folder: world/results\n---\n\n| Table | Rolls | Label |\n|---|---|---|\n`;
const wf = parseWorkflow(content, "test");
expect(wf.resultsFolder).toBe("world/results");
});
it("parses template-file from frontmatter", () => {
const content = `---\ntemplate-file: world/workflows/templates/test.md\n---\n\n| Table | Rolls | Label |\n|---|---|---|\n`;
const wf = parseWorkflow(content, "test");
expect(wf.templateFile).toBe("world/workflows/templates/test.md");
});
it("parses both frontmatter fields", () => {
const content = `---\nresults-folder: world/results\ntemplate-file: world/workflows/templates/t.md\n---\n\n| Table | Rolls | Label |\n|---|---|---|\n`;
const wf = parseWorkflow(content, "test");
expect(wf.resultsFolder).toBe("world/results");
expect(wf.templateFile).toBe("world/workflows/templates/t.md");
});
it("returns no steps when no table header found", () => {
const content = `---\nresults-folder: x\n---\n\nSome text with no table.`;
expect(parseWorkflow(content, "test").steps).toEqual([]);
});
it("parses a single table step", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/encounters]] | 1 | Encounters |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps).toHaveLength(1);
expect(wf.steps[0]).toEqual(tableStep("world/tables/encounters", 1, "Encounters"));
});
it("strips [[wikilink]] syntax from table cell", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/my table]] | 1 | |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps[0].tablePath).toBe("world/tables/my table");
expect(wf.steps[0].kind).toBe("table");
});
it("parses a dice step", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| 2d6+6 | 1 | (2d6+6) |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps).toHaveLength(1);
expect(wf.steps[0]).toEqual(diceStep("2d6+6", 1, "(2d6+6)"));
});
it("parses dice step with no label", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| d20 | 1 | |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps[0].kind).toBe("dice");
expect(wf.steps[0].diceFormula).toBe("d20");
expect(wf.steps[0].label).toBeUndefined();
});
it("parses multiple rolls", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/enc]] | 3 | enc |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps[0].rolls).toBe(3);
});
it("parses multiple steps", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/a]] | 1 | A |\n| 2d6 | 2 | (2d6) |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps).toHaveLength(2);
expect(wf.steps[0].kind).toBe("table");
expect(wf.steps[1].kind).toBe("dice");
});
it("skips rows with missing or invalid roll count", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/a]] | abc | A |\n| [[world/tables/b]] | 0 | B |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps).toHaveLength(0);
});
it("empty label becomes undefined", () => {
const content = `| Table | Rolls | Label |\n|---|---|---|\n| [[world/tables/enc]] | 1 | |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps[0].label).toBeUndefined();
});
it("is case-insensitive for table header", () => {
const content = `| TABLE | ROLLS | LABEL |\n|---|---|---|\n| [[world/tables/enc]] | 1 | enc |\n`;
const wf = parseWorkflow(content, "test");
expect(wf.steps).toHaveLength(1);
});
});
// ── buildWorkflowContent ──────────────────────────────────────────────────────
describe("buildWorkflowContent", () => {
it("produces minimal valid output with no steps", () => {
const wf: Workflow = { name: "test", steps: [] };
const out = buildWorkflowContent(wf);
expect(out).toContain("---\n---");
expect(out).toContain("| Table | Rolls | Label |");
});
it("includes results-folder in frontmatter when set", () => {
const wf: Workflow = { name: "t", resultsFolder: "world/results", steps: [] };
expect(buildWorkflowContent(wf)).toContain("results-folder: world/results");
});
it("includes template-file in frontmatter when set", () => {
const wf: Workflow = { name: "t", templateFile: "world/workflows/templates/t.md", steps: [] };
expect(buildWorkflowContent(wf)).toContain("template-file: world/workflows/templates/t.md");
});
it("omits optional frontmatter fields when absent", () => {
const wf: Workflow = { name: "t", steps: [] };
const out = buildWorkflowContent(wf);
expect(out).not.toContain("results-folder");
expect(out).not.toContain("template-file");
});
it("serializes table step with wikilink", () => {
const wf: Workflow = { name: "t", steps: [tableStep("world/tables/enc", 1, "Encounters")] };
expect(buildWorkflowContent(wf)).toContain("| [[world/tables/enc]] | 1 | Encounters |");
});
it("serializes dice step without wikilink brackets", () => {
const wf: Workflow = { name: "t", steps: [diceStep("2d6+6", 1, "(2d6+6)")] };
expect(buildWorkflowContent(wf)).toContain("| 2d6+6 | 1 | (2d6+6) |");
});
it("serializes step with empty label as empty cell", () => {
const wf: Workflow = { name: "t", steps: [tableStep("world/tables/enc", 1, undefined)] };
expect(buildWorkflowContent(wf)).toContain("| [[world/tables/enc]] | 1 | |");
});
it("round-trips through parse → build → parse", () => {
const original: Workflow = {
name: "roundtrip",
resultsFolder: "world/results",
templateFile: "world/workflows/templates/roundtrip.md",
steps: [
tableStep("world/tables/enc", 2, "Encounters"),
diceStep("2d6", 1, "(2d6)"),
],
};
const serialized = buildWorkflowContent(original);
const parsed = parseWorkflow(serialized, "roundtrip");
expect(parsed.resultsFolder).toBe(original.resultsFolder);
expect(parsed.templateFile).toBe(original.templateFile);
expect(parsed.steps).toHaveLength(2);
expect(parsed.steps[0]).toEqual(original.steps[0]);
expect(parsed.steps[1]).toEqual(original.steps[1]);
});
});
// ── stepVarName ───────────────────────────────────────────────────────────────
describe("stepVarName", () => {
it("uses label when set, replacing spaces with underscores", () => {
const step: WorkflowStep = { kind: "table", tablePath: "world/tables/enc", rolls: 1, label: "My Table" };
expect(stepVarName(step)).toBe("My_Table");
});
it("uses table basename when no label", () => {
const step: WorkflowStep = { kind: "table", tablePath: "world/tables/encounters", rolls: 1 };
expect(stepVarName(step)).toBe("encounters");
});
it("replaces spaces in basename with underscores", () => {
const step: WorkflowStep = { kind: "table", tablePath: "world/tables/random encounters", rolls: 1 };
expect(stepVarName(step)).toBe("random_encounters");
});
it("uses formula in parens for dice step with no label", () => {
const step: WorkflowStep = { kind: "dice", tablePath: "", diceFormula: "2d6+6", rolls: 1 };
expect(stepVarName(step)).toBe("(2d6+6)");
});
it("falls back to 'dice' when dice step has no formula", () => {
const step: WorkflowStep = { kind: "dice", tablePath: "", rolls: 1 };
expect(stepVarName(step)).toBe("dice");
});
it("label takes priority over dice formula", () => {
const step: WorkflowStep = { kind: "dice", tablePath: "", diceFormula: "2d6", rolls: 1, label: "damage" };
expect(stepVarName(step)).toBe("damage");
});
it("uses full path basename (after last slash)", () => {
const step: WorkflowStep = { kind: "table", tablePath: "a/b/c/my table", rolls: 1 };
expect(stepVarName(step)).toBe("my_table");
});
});
// ── stepPlaceholder ───────────────────────────────────────────────────────────
describe("stepPlaceholder", () => {
const tableStepFixed: WorkflowStep = { kind: "table", tablePath: "world/tables/enc", rolls: 1, label: "Encounters" };
it("returns $label for single-roll step", () => {
expect(stepPlaceholder(tableStepFixed, 0)).toBe("$Encounters");
});
it("returns $label_N for multi-roll step", () => {
const multi: WorkflowStep = { ...tableStepFixed, rolls: 3 };
expect(stepPlaceholder(multi, 0)).toBe("$Encounters_1");
expect(stepPlaceholder(multi, 1)).toBe("$Encounters_2");
expect(stepPlaceholder(multi, 2)).toBe("$Encounters_3");
});
it("uses formula-in-parens for dice step placeholder", () => {
const step: WorkflowStep = { kind: "dice", tablePath: "", diceFormula: "2d6", rolls: 1 };
expect(stepPlaceholder(step, 0)).toBe("$(2d6)");
});
it("uses label for dice step when label is set", () => {
const step: WorkflowStep = { kind: "dice", tablePath: "", diceFormula: "2d6", rolls: 1, label: "damage" };
expect(stepPlaceholder(step, 0)).toBe("$damage");
});
});
// ── generateDefaultTemplate ───────────────────────────────────────────────────
describe("generateDefaultTemplate", () => {
it("returns empty string for no steps", () => {
expect(generateDefaultTemplate([])).toBe("");
});
it("generates heading and placeholder for a single-roll table step", () => {
const steps: WorkflowStep[] = [tableStep("world/tables/enc", 1, "Encounters")];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("## Encounters");
expect(tmpl).toContain("$Encounters");
});
it("generates multiple placeholders for multi-roll step", () => {
const steps: WorkflowStep[] = [tableStep("world/tables/enc", 3, "Encounters")];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("$Encounters_1");
expect(tmpl).toContain("$Encounters_2");
expect(tmpl).toContain("$Encounters_3");
expect(tmpl).not.toContain("$Encounters\n");
});
it("uses formula as heading when dice step has no label", () => {
const steps: WorkflowStep[] = [diceStep("2d6", 1)];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("## 2d6");
expect(tmpl).toContain("$(2d6)");
});
it("uses label as heading when set", () => {
const steps: WorkflowStep[] = [diceStep("2d6", 1, "damage")];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("## damage");
expect(tmpl).toContain("$damage");
});
it("generates a section per step", () => {
const steps: WorkflowStep[] = [
tableStep("world/tables/enc", 1, "Encounters"),
diceStep("2d6+6", 1, "(2d6+6)"),
];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("## Encounters");
expect(tmpl).toContain("## (2d6+6)");
});
it("uses 'Table N' heading for unlabelled table step", () => {
const steps: WorkflowStep[] = [tableStep("world/tables/enc", 1)];
const tmpl = generateDefaultTemplate(steps);
expect(tmpl).toContain("## Table 1");
});
});
// ── requiredPlaceholders ──────────────────────────────────────────────────────
describe("requiredPlaceholders", () => {
it("returns empty array for no steps", () => {
expect(requiredPlaceholders([])).toEqual([]);
});
it("returns one placeholder per single-roll step", () => {
const steps: WorkflowStep[] = [
tableStep("world/tables/enc", 1, "enc"),
diceStep("2d6", 1, "roll"),
];
expect(requiredPlaceholders(steps)).toEqual(["$enc", "$roll"]);
});
it("returns N placeholders for a multi-roll step", () => {
const steps: WorkflowStep[] = [tableStep("world/tables/enc", 3, "enc")];
expect(requiredPlaceholders(steps)).toEqual(["$enc_1", "$enc_2", "$enc_3"]);
});
it("accumulates placeholders across multiple steps", () => {
const steps: WorkflowStep[] = [
tableStep("world/tables/a", 2, "A"),
tableStep("world/tables/b", 1, "B"),
];
expect(requiredPlaceholders(steps)).toEqual(["$A_1", "$A_2", "$B"]);
});
});

View file

@ -1,10 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"esModuleInterop": true,
"paths": {
"obsidian": ["./tests/__mocks__/obsidian.ts"]
}
},
"include": ["tests/**/*.ts"]
}
{
"extends": "./tsconfig.json",
"compilerOptions": {
"esModuleInterop": true,
"paths": {
"obsidian": ["./tests/__mocks__/obsidian.ts"]
}
},
"include": ["tests/**/*.ts"]
}

View file

@ -1,10 +1,11 @@
{
"0.1.0": "1.12.0",
"1.0.0": "1.12.0",
"1.0.1": "1.12.0",
"1.0.2": "1.12.0",
"1.0.3": "1.12.0",
"1.0.4": "1.12.0",
"1.0.5": "1.12.0",
"1.0.6": "1.12.0"
}
"0.1.0": "1.12.0",
"1.0.0": "1.12.0",
"1.0.1": "1.12.0",
"1.0.2": "1.12.0",
"1.0.3": "1.12.0",
"1.0.4": "1.12.0",
"1.0.5": "1.12.0",
"1.0.6": "1.12.0",
"1.0.18": "1.12.0"
}