feat: Implement Daggerheart Path Browser module to display and copy Active Effect data paths.

This commit is contained in:
CPTN Cosmo 2026-03-19 21:49:16 +01:00
commit 922814242d
7 changed files with 387 additions and 0 deletions

33
README.md Normal file
View file

@ -0,0 +1,33 @@
# Daggerheart Path Browser
A Foundry VTT module that adds a browser to easily find and copy Active Effect data paths in the Daggerheart system.
## Features
- Easily browse available data paths for Active Effects in Daggerheart.
- Search and filter functionality to quickly find what you need.
- Simple, distinct UI accessible directly from your game.
## Requirements
- **Foundry VTT**: Version 13 or higher
- **System**: Daggerheart 1.9.4 or higher
## Installation
### Method 1: Foundry Native
1. Open up the Foundry VTT application and go to the **Add-on Modules** tab.
2. Click **Install Module**.
3. Search for "Daggerheart Path Browser".
4. Click **Install**.
### Method 2: Manifest URL
1. Open up the Foundry VTT application and go to the **Add-on Modules** tab.
2. Click **Install Module**.
3. Paste the following link into the **Manifest URL** field at the bottom:
`https://git.geeks.gay/cosmo/dh-path-browser/raw/branch/main/module.json`
4. Click **Install**.
## Usage
Once installed and enabled in your world, you can access the Daggerheart Path Browser to find and copy Active Effect data paths.
## Author
- **Cosmo** (Discord: `cptn_cosmo`)

39
module.json Normal file
View file

@ -0,0 +1,39 @@
{
"id": "dh-path-browser",
"title": "Daggerheart Path Browser",
"description": "Adds a browser to easily find and copy Active Effect data paths in Daggerheart.",
"url": "https://git.geeks.gay/cosmo/dh-path-browser",
"manifest": "https://git.geeks.gay/cosmo/dh-path-browser/raw/branch/main/module.json",
"download": "https://git.geeks.gay/cosmo/dh-path-browser/releases/download/1.0.0/dh-path-browser.zip",
"version": "1.0.0",
"compatibility": {
"minimum": "13",
"verified": "13"
},
"authors": [
{
"name": "Cosmo",
"email": "cptncosmo@gmail.com",
"url": "https://git.geeks.gay/cosmo",
"discord": "cptn_cosmo",
"flags": {}
}
],
"esmodules": [
"scripts/main.mjs"
],
"styles": [
"styles/styles.css"
],
"relationships": {
"systems": [
{
"id": "daggerheart",
"type": "system",
"compatibility": {
"minimum": "1.9.4"
}
}
]
}
}

157
scripts/app.mjs Normal file
View file

@ -0,0 +1,157 @@
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
export class DhPathBrowserApp extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(options) {
super(options);
this.paths = this.constructor.getChangeChoices();
}
static DEFAULT_OPTIONS = {
id: "dh-path-browser-app",
classes: ["daggerheart", "dh-path-browser", "dh-style"],
tag: "form",
window: {
title: "Data Path Browser",
icon: "fa-solid fa-code",
resizable: true,
contentClasses: ["standard-form"]
},
position: {
width: 600,
height: 700
},
actions: {
copyPath: DhPathBrowserApp.#copyPath
}
};
static PARTS = {
header: { template: "modules/dh-path-browser/templates/header.hbs" },
list: {
template: "modules/dh-path-browser/templates/list.hbs",
scrollable: [".paths-list"]
}
};
async _prepareContext(options) {
const context = await super._prepareContext(options);
const groups = {};
for (const p of this.paths) {
if (!groups[p.group]) groups[p.group] = [];
groups[p.group].push(p);
}
context.groups = Object.entries(groups).map(([group, paths]) => ({
group,
paths: paths.sort((a, b) => a.label.localeCompare(b.label))
})).sort((a, b) => a.group.localeCompare(b.group));
return context;
}
_attachPartListeners(partId, htmlElement, options) {
super._attachPartListeners(partId, htmlElement, options);
if (partId === "header") {
const searchInput = htmlElement.querySelector('input[name="search"]');
if (searchInput) {
searchInput.addEventListener("input", (e) => this.#filterPaths(e.target.value));
}
}
}
#filterPaths(query) {
query = query.toLowerCase();
const listElement = this.element.querySelector(".paths-list");
if (!listElement) return;
const items = listElement.querySelectorAll(".path-item");
items.forEach(item => {
const label = item.dataset.label.toLowerCase();
const value = item.dataset.value.toLowerCase();
if (label.includes(query) || value.includes(query)) {
item.style.display = "";
} else {
item.style.display = "none";
}
});
const groups = listElement.querySelectorAll(".path-group");
groups.forEach(group => {
const visibleItems = Array.from(group.querySelectorAll(".path-item")).filter(i => i.style.display !== "none");
group.style.display = visibleItems.length > 0 ? "" : "none";
});
}
static async #copyPath(event, target) {
const path = target.dataset.value;
if (path) {
const fullPath = `@system.${path}`;
await navigator.clipboard.writeText(fullPath);
ui.notifications.info(`Copied ${fullPath} to clipboard!`);
}
}
static getChangeChoices() {
const ignoredActorKeys = ['config', 'DhEnvironment', 'DhParty'];
const getAllLeaves = (root, group, parentPath = '') => {
const leaves = [];
const rootKey = `${parentPath ? `${parentPath}.` : ''}${root.name}`;
for (const field of Object.values(root.fields)) {
if (field instanceof foundry.data.fields.SchemaField)
leaves.push(...getAllLeaves(field, group, rootKey));
else
leaves.push({
value: `${rootKey}.${field.name}`,
label: game.i18n.localize(field.label),
hint: game.i18n.localize(field.hint),
group
});
}
return leaves;
};
return Object.keys(game.system.api.models.actors).reduce((acc, key) => {
if (ignoredActorKeys.includes(key)) return acc;
const model = game.system.api.models.actors[key];
const group = game.i18n.localize(model.metadata.label);
const attributes = CONFIG.Token.documentClass.getTrackedAttributes(model.metadata.type);
const getTranslations = path => {
if (path === 'resources.hope.max')
return {
label: game.i18n.localize('DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxHope.label'),
hint: ''
};
const field = model.schema.getField(path);
return {
label: field ? game.i18n.localize(field.label) : path,
hint: field ? game.i18n.localize(field.hint) : ''
};
};
const bars = attributes.bar.flatMap(x => {
const baseJoined = x.join('.');
return [
{ value: `${baseJoined}.max`, ...getTranslations(`${baseJoined}.max`), group },
{ value: `${baseJoined}.value`, ...getTranslations(`${baseJoined}.value`), group }
];
});
const values = attributes.value.flatMap(x => {
const joined = x.join('.');
return { value: joined, ...getTranslations(joined), group };
});
const bonuses = getAllLeaves(model.schema.fields.bonuses, group);
const rules = getAllLeaves(model.schema.fields.rules, group);
acc.push(...bars, ...values, ...rules, ...bonuses);
return acc;
}, []);
}
}

21
scripts/main.mjs Normal file
View file

@ -0,0 +1,21 @@
import { DhPathBrowserApp } from "./app.mjs";
Hooks.on("init", () => {
console.log("Daggerheart Path Browser | Initializing module");
});
Hooks.on("renderDaggerheartMenu", (app, html, data) => {
const button = document.createElement("button");
button.type = "button";
button.innerHTML = `<i class="fa-solid fa-code"></i> ${game.i18n.localize("Browse Data Paths")}`;
button.classList.add("dh-path-browser-btn");
button.addEventListener("click", () => {
new DhPathBrowserApp().render(true);
});
const container = html.querySelector("div");
if (container) {
container.appendChild(button);
}
});

109
styles/styles.css Normal file
View file

@ -0,0 +1,109 @@
.dh-path-browser-btn {
margin-top: 10px;
width: 100%;
}
.dh-path-browser.application {
display: flex;
flex-direction: column;
}
.dh-path-browser .window-content {
display: flex;
flex-direction: column;
padding: 10px;
gap: 10px;
}
.dh-path-browser .paths-header {
flex: 0 0 auto;
padding-bottom: 10px;
border-bottom: 1px solid var(--color-border-light-1);
}
.dh-path-browser .paths-list {
flex: 1 1 auto;
overflow-y: auto;
padding-right: 5px;
}
.dh-path-browser .path-group {
margin-bottom: 15px;
}
.dh-path-browser .path-group .group-header {
margin: 0 0 5px 0;
padding-bottom: 3px;
border-bottom: 1px solid var(--color-border-light-2);
font-size: 1.2em;
font-weight: bold;
}
.dh-path-browser .path-group .item-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.dh-path-browser .path-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: 4px;
background: var(--color-bg-option);
}
.dh-path-browser .path-item:hover {
background: var(--color-bg-option-hover);
border-color: var(--color-border-light-2);
}
.dh-path-browser .path-item-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.dh-path-browser .path-label {
font-weight: bold;
}
.dh-path-browser .path-value {
font-size: 0.9em;
color: var(--color-text-dark-secondary);
background: rgba(0,0,0,0.05);
padding: 2px 4px;
border-radius: 3px;
user-select: all;
}
.theme-dark .dh-path-browser .path-value,
.dh-style .dh-path-browser .path-value {
color: var(--color-text-light-secondary);
background: rgba(255,255,255,0.1);
}
.dh-path-browser .path-item .copy-btn {
flex: 0 0 32px;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--color-border-light-1);
border-radius: 4px;
cursor: pointer;
line-height: 1;
color: inherit;
}
.dh-path-browser .path-item .copy-btn:hover {
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 5px var(--color-shadow-primary);
}

8
templates/header.hbs Normal file
View file

@ -0,0 +1,8 @@
<header class="paths-header">
<div class="form-group">
<label><i class="fa-solid fa-search"></i> Search Paths</label>
<div class="form-fields">
<input type="text" name="search" placeholder="Filter by name or path...">
</div>
</div>
</header>

20
templates/list.hbs Normal file
View file

@ -0,0 +1,20 @@
<div class="paths-list">
{{#each groups}}
<div class="path-group">
<h3 class="group-header">{{group}}</h3>
<ul class="item-list">
{{#each paths}}
<li class="path-item" data-label="{{label}}" data-value="{{value}}" {{#if hint}}data-tooltip="{{hint}}"{{/if}}>
<div class="path-item-info">
<span class="path-label">{{label}}</span>
<code class="path-value">@system.{{value}}</code>
</div>
<button type="button" class="copy-btn" data-action="copyPath" data-value="{{value}}" data-tooltip="Copy Data Path">
<i class="fa-solid fa-copy"></i>
</button>
</li>
{{/each}}
</ul>
</div>
{{/each}}
</div>