Skip to content

Instantly share code, notes, and snippets.

@ebullient
Last active November 14, 2024 20:30
Show Gist options
  • Select an option

  • Save ebullient/2d21c2e614be701daf5825cbb0ce78b4 to your computer and use it in GitHub Desktop.

Select an option

Save ebullient/2d21c2e614be701daf5825cbb0ce78b4 to your computer and use it in GitHub Desktop.
Managing a campaign in obsidian

Too much to explain. But here are some related scripts for managing / creating session notes.


  • missing.js - re-generate page content listing missing things (dangling or missing references)
  • heist-summaries.js - re-generate page content to contain all session summaries
class Campaign {
constructor() {
this.EVENT_CODES = ['πŸͺ•', 'πŸ“°', '🧡', 'πŸ‘€', '😈', 'πŸ—£οΈ', 'πŸ—Ώ', '🐲', '😡', 'πŸ₯Έ', '🦹',
'πŸ‘Ί', 'πŸ’ƒ', '🧝🏿', '🌿', 'πŸͺ¬', '🎻', '🏰', '🌹', 'πŸ§™β€β™€οΈ', 'πŸ‘Ύ', 'βš”οΈ', 'πŸ€'];
this.monsterSize = ['Tiny', 'Small', 'Medium', 'Large', 'Huge', 'Gargantuan'];
this.monsterType = ['Aberration', 'Beast', 'Celestial', 'Construct',
'Dragon', 'Elemental', 'Fey', 'Fiend', 'Giant', 'Humanoid', 'Monstrosity', 'Ooze',
'Plant', 'Undead'];
this.eventRegexp = /(<span[^>]*>)([\s\S]*?)<\/span>/g;
this.utils = () => window.customJS.Utils;
this.allTags = () => this.utils().allTags();
/**
* Prompt to select a target folder from a list of potential folders
* for a new file from a filtered list of subfolders of
* the specified folder (specify "/" or "" for the vault root)
*/
this.chooseFolder = async (tp, folderPath) => {
const folders = this.utils().foldersByCondition(folderPath, (tfolder) => this.chooseFolderFilter(tfolder.path))
.map(f => f.path);
if (folders.length > 0) {
const choice = await tp.system.suggester(folders, folders);
if (!choice) {
console.warn("No choice selected. Using 'compendium'");
return 'compendium';
}
return choice;
}
return folderPath;
};
/**
* Folders that should be skipped when prompting for a folder
* to add a new note to.
* @param {string} fullname full path of folder (from vault root)
* @return {boolean} true to include folder, false to exclude it
*/
this.chooseFolderFilter = (fullname) => !fullname.startsWith("assets")
&& !fullname.contains("archive")
&& !fullname.contains("compendium/5e");
/**
* Prompt to select a monster size
* @param {Templater} tp The templater object
* @returns {string} The chosen monster size
*/
this.chooseMonsterSize = async (tp) => {
return await tp.system.suggester(this.monsterSize, this.monsterSize);
};
/**
* Prompt to select a monster type
* @param {Templater} tp The templater object
* @returns {string} The chosen monster type
*/
this.chooseMonsterType = async (tp) => {
return await tp.system.suggester(this.monsterType, this.monsterType);
};
/**
* Prompt to select a tag from a list of potential tags for a new file.
* The list will contain all tags that match the specified prefix,
* and will include '--' to indicate none. If no value is chosen,
* it will return the provided default value.
* @param {Templater} tp The templater object
* @param {string[]} allTags All tags in the vault
* @param {string} prefix The prefix to filter tags by
* @param {string} defaultValue The default value to use if no value is chosen
* @returns {string} The chosen tag
*/
this.chooseTag = async (tp, allTags = [], prefix, defaultValue = undefined) => {
const filter = prefix;
// tags for all files, not current file
const values = allTags
.filter(tag => tag.startsWith(filter))
.sort();
console.log("chooseTag", filter, defaultValue, values);
values.unshift('--'); // add to the beginning
const choice = await tp.system.suggester(values, values);
if (!choice || choice === '--') {
console.log(`No choice selected. Using ${defaultValue}`);
return defaultValue;
}
return choice;
};
/**
* Prompt to select a tag from a list of potential tags for a new file.
* The list will contain all tags that match the specified prefix,
* and will include '--' to indicate none. If no value is chosen,
* it will return an empty string.
* @param {Templater} tp The templater object
* @param {string} prefix The prefix to filter tags by
* @returns {string} The chosen tag or an empty string
*/
this.chooseTagOrEmpty = async (tp, allTags = [], prefix) => {
const result = await this.chooseTag(tp, allTags, prefix, '--');
if (result && result != '--') {
return result;
}
return '';
};
/**
* Map a folder to a tag
* @param {string} folder full path of folder (from vault root)
* @returns {string} tag that should be associated with this folder
*/
this.folderToTag = (foldername) => foldername.substring(0, foldername.indexOf('/'));
/**
* Find the last file in a filtered list of files
* @param {Templater} tp The templater object
* @returns {TFile | null} The name of the last file in the current folder
* @see prevNextFilter
*/
this.lastFile = async (tp, path = '') => {
const folder = path ? path : tp.file.folder(true);
const pathRegexp = this.utils().segmentFilterRegex(folder);
const fileList = this.utils().filesWithPath(pathRegexp, true)
.filter(f => this.prevNextFilter(f));
return fileList.length > 0 ? fileList[fileList.length - 1] : null;
};
this.nextSession = async (tp, padSize = 3, path = '') => {
const lastFile = await this.lastFile(tp, path);
if (!lastFile) {
return {
next: '1',
nextFile: '001-',
};
}
const session = lastFile.name.replace(/^(\d+).*$/g, "$1");
const next = parseInt(session) + 1;
const nextPrefix = `${next}`.padStart(padSize, '0');
return {
next: nextPrefix,
nextName: nextPrefix + '-',
nextFile: `${lastFile.parent.path}/${nextPrefix}-.md`,
prev: session,
prevName: lastFile.name.replace('.md', ''),
prevFile: lastFile.path,
tag: this.folderToTag(lastFile.parent.path)
};
};
/**
* Pad a string to two characters with a leading 0 (month or day)
* @param {string} x
* @returns {string}
*/
this.pad = (x) => {
return `${x}`.padStart(2, '0');
};
/**
* Links for previous and next document (based on name-sort)
*/
this.prevNext = async (tp) => {
const folder = tp.file.folder(true);
const filename = tp.file.title + '.md';
// remove files that don't match the filter from the list
const pathRegexp = this.utils().segmentFilterRegex(folder);
const fileList = this.utils().filesWithPath(pathRegexp, true)
.filter(f => this.prevNextFilter(f));
console.log(fileList);
const result = {};
for (let i = 0; i < fileList.length; i++) {
if (fileList[i].name == filename) {
if (i > 0) {
result.prevFile = fileList[i - 1].path;
result.prev = `[← previous](${fileList[i - 1].path})`;
}
if (i < fileList.length - 1) {
result.nextFile = fileList[i + 1].path;
result.next = `[next β†’](${fileList[i + 1].path})`;
}
break;
}
}
console.log("prevNext", filename, result);
// result: { prev?: .., next?: ... }
return result;
};
/**
* Files that should be skipped when calculating previous and next links.
* Must return a boolean.
* @param {TFile} file The file to check
* @return {boolean} true to include file, false to exclude it
*/
this.prevNextFilter = (file) => {
return !this.utils().isFolderNote(file)
&& !file.name.contains('Untitled')
&& !file.name.contains('encounter'); // encounter log
};
/**
*
*/
this.sessionFileNamePattern = (folder) => {
if (folder.startsWith("witchlight")) {
return /^session-(\d{3}).*$/g;
}
else {
return /^.*(\d{4}-\d{2}-\d{2}).*$/g;
}
};
this.tableRoll = async (lookup) => {
const current = this.app.workspace.getActiveFile();
const diceRoller = window.DiceRoller;
const re = /dice: (\[\]\(.*?\))/g;
let match = re.exec(lookup);
let result = null;
let input;
do {
input = match ? match[1] : lookup;
result = await diceRoller.parseDice(input, current ? current.path : '');
match = re.exec(result.result);
console.log("tableRoll", input, result.result, match);
} while (match != null);
return result.result;
};
/**
* Change a Title string into a desired filename format,
* e.g. "Pretty Name" to pretty-name (lower-kebab / slugified)
*/
this.toFileName = (name) => {
return this.utils().lowerKebab(name);
};
// --- Campaign-specific functions
// Resolve table roll from template
this.faire = async (type) => {
return await this.tableRoll(`dice: [](heist/waterdeep/places/sea-maidens-faire.md#^${type})`);
};
// Resolve table roll from template
this.mood = async () => {
return await this.tableRoll("dice: [](assets/tables/mood-tables.md#^mood-table)");
};
// Resolve table roll from template
this.news = async () => {
const paper = await this.tableRoll(`dice: [](heist/tables/news.md#^papers)`);
const news = await this.tableRoll(`dice: [](heist/tables/news.md#^news)`);
return `${paper} ${news}`;
};
this.thread = async () => {
const paper = await this.tableRoll(`dice: [](heist/tables/news.md#^papers)`);
const news = await this.tableRoll(`dice: [](heist/tables/news.md#^thread)`);
return `${paper} ${news}`;
};
this.reviews = async () => {
const paper = await this.tableRoll(`dice: [](heist/tables/news.md#^papers)`);
const news = await this.tableRoll(`dice: [](heist/tables/news.md#^reviews)`);
return `${paper} ${news}`;
};
this.rumors = async () => {
return await this.tableRoll(`dice: [](heist/tables/rumors.md#^rumors)`);
};
// Resolve table roll from template
this.tavern = async (type) => {
let result = await this.tableRoll(`dice: [](heist/tables/trollskull-manor-tables.md#^${type})`);
if (type == 'visiting-patrons') {
result = result.replace(/,? ?\(\d+\) /g, '\n - ');
}
while (result.contains("%mood%")) {
const mood = await this.mood();
result = result.replace("%mood%", `_[${mood}]_`);
}
if (result.contains("πŸ”Ή")) {
result = result.replace(/\s*πŸ”Ή\s*/g, '\n > ');
console.log(result);
}
return result;
};
// Resolve table roll from template
this.weather = async (season) => {
return await this.tableRoll(`dice: [](heist/tables/waterdeep-weather.md#^${season})`);
};
this.eventSpan = (match, suffix = '') => {
const text = match[1];
const sort = text.replace(/.*data-date=['"](.*?)['"].*/g, '$1');
const date = text.replace(/.*data-date=['"](.*?)-\d{2}['"].*/g, '$1');
let name = text.contains('data-name="')
? text.replace(/.*data-name="(.*?)".*/g, '$1')
: text.replace(/.*data-name='(.*?)'.*/g, '$1');
if (!name.endsWith('.') && !name.endsWith('!')) {
name += '.';
}
let data = match[2].trim();
if (data.length > 0 && !data.endsWith('.') && !data.endsWith('!')) {
data += '.';
}
return `<span class="timeline" data-date="${sort}">\`${date}\` *${name}* ${data} ${suffix}</span>`;
};
// Harptos Calendar
this.compareHarptosDate = (a, b) => {
const as = a.toLowerCase().split('-');
const bs = b.toLowerCase().split('-');
// compare year as[0], then month as[1], then day as[2], then offset as as[3]
if (as[0] == bs[0]) {
if (as[1] == bs[1]) {
if (as[2] == bs[2]) {
if (as.length > 3 && bs.length > 3) {
return Number(as[3]) - Number(bs[3]);
}
return 0;
}
return Number(as[2]) - Number(bs[2]);
}
return this.monthSort(as[1]) - this.monthSort(bs[1]);
}
return Number(as[0]) - Number(bs[0]);
};
/**
* Get the faerun season for a given month and day
* @param {string|number} m a number (human index, or bumped crazy non-human index) or name of the month
* @param {number} d The day of the month
* @returns {string} the season
*/
this.faerunSeason = (m, d) => {
if (typeof m === 'string') {
m = m.toLowerCase();
}
switch (m) {
case 'hammer':
case 30:
case 1:
case 'midwinter':
case 31:
case 'alturiak':
case 2:
case 32:
return 'winter';
case 'tarsakh':
case 34:
case 4:
case 'mirtul':
case 36:
case 5:
case 'greengrass':
case 35:
return 'spring';
case 'flamerule':
case 38:
case 7:
case 'eleasis':
case 41:
case 8:
case 'midsummer':
case 39:
case 'shieldmeet':
case 40:
return 'summer';
case 'marpenoth':
case 44:
case 10:
case 'uktar':
case 45:
case 11:
case 'highharvestide':
case 43:
case 'the feast of the moon':
case 'feast of the moon':
case 46:
return 'autumn';
case 'ches':
case 33:
case 3:
return d < 19
? 'winter'
: 'spring';
case 'kythorn':
case 37:
case 6:
return d < 20
? 'spring'
: 'summer';
case 'elient':
case 42:
case 9:
return d < 21
? 'summer'
: 'autumn';
case 'nightal':
case 47:
case 12:
return d < 20
? 'autumn'
: 'winter';
}
};
/**
* Create a sorting value for months that is out of the confusing
* human range (where humans use 1-12, but calendarium uses 0-indexed numbers
* that include intercalary days as months)
* @param {string} m Month name
* @returns number for sorting bumped by 30
*/
this.monthSort = (m) => {
switch (m) {
case 'hammer': return 30;
case 'midwinter': return 31;
case 'alturiak': return 32;
case 'ches': return 33;
case 'tarsakh': return 34;
case 'greengrass': return 35;
case 'mirtul': return 36;
case 'kythorn': return 37;
case 'flamerule': return 38;
case 'midsummer': return 39;
case 'shieldmeet': return 40;
case 'eleasis': return 41;
case 'eleint': return 42;
case 'highharvestide': return 43;
case 'marpenoth': return 44;
case 'uktar': return 45;
case 'feast':
case 'feast of the moon':
return 46;
case 'nightal': return 47;
}
};
/**
* Map the month and day to pretty names according to the Harptos Calendar.
*/
this.monthName = (m) => {
if (typeof m === 'string') {
return m;
}
switch (m) {
case 30:
case 1:
return 'Hammer';
case 31:
return 'Midwinter';
case 32:
case 2:
return 'Alturiak';
case 33:
case 3:
return 'Ches';
case 34:
case 4:
return 'Tarsakh';
case 35:
return 'Greengrass';
case 36:
case 5:
return 'Mirtul';
case 37:
case 6:
return 'Kythorn';
case 38:
case 7:
return 'Flamerule';
case 39:
return 'Midsummer';
case 40:
return 'Shieldmeet';
case 41:
case 8:
return 'Elesias';
case 42:
case 9:
return 'Eleint';
case 43:
return 'Highharvestide';
case 44:
case 10:
return 'Marpenoth';
case 45:
case 11:
return 'Uktar';
case 46:
return 'Feast of the Moon';
case 47:
case 12:
return 'Nightal';
}
};
/**
* Harptos filename and heading
* @param {string} dateStr date to use for new file (result of prompt)
* @returns {object} filename (padded date), pretty heading (formatted date), season, date object, monthName
*/
this.harptosDay = (dateStr) => {
const date = this.splitDateString(dateStr);
return {
filename: `${date.year}-${date.monthName}-${this.pad(date.day)}`.toLowerCase(),
sort: `${date.year}-${date.month}-${this.pad(date.day)}`,
heading: `${date.monthName} ${date.day}, ${date.year}`,
season: this.faerunSeason(date.month, date.day),
date: date,
monthName: date.monthName
};
};
/**
* Calculate the next day that should be logged, according to the Harptos calendar.
* This assumes files with the following format:
* - single day: 1498-ches-09
* - several days: 1498-klythorn-09-11-optional-other-stuff
*
* Once it has found the last day.. figure out the _next_ day, with rollover
* for the year.
* @return {DateTag} the discovered date (proposal) and the tag associated with this folder
*/
this.nextHarptosDay = async (tp) => {
const folder = tp.file.folder(true);
console.log("Looking for files in %s", folder);
const pathRegexp = this.utils().segmentFilterRegex(folder);
const files = this.utils().filesWithPath(pathRegexp)
.filter(f => f.name.match(/^.*\d{4}-[^-]+-.*/))
.map(f => f.path);
// sort by harptos date in filename
files.sort((a, b) => this.compareHarptosDate(a.slice(a.lastIndexOf('/')), b.slice(b.lastIndexOf('/'))));
const lastLog = files.pop();
const date = this.splitDateString(lastLog);
console.log("Found lastlog", lastLog, date);
// Find the next available day
/* eslint-disable no-fallthrough */
switch (date.month) {
case 39: // midsummer
if (date.year % 4 == 0) {
date.day = 2; // Shieldmeet is 2nd day of intercalary month
date.month += 1;
break;
}
case 31: // midwinter
case 35: // greengrass
case 40: // shieldmeet
case 43: // highharvestide
case 46: // feast of the moon
date.day = 1;
date.month += 1;
break;
case 47: // nightal, end of year
if (date.day == 30) {
date.month = 30;
date.year += 1;
date.day = 1;
}
else {
date.day += 1;
}
break;
default:
if (date.day == 30) {
date.day = 1;
date.month += 1;
}
else {
date.day += 1;
}
break;
}
/* eslint-enable no-fallthrough */
return {
date: `${date.year}-${this.monthName(date.month)}-${this.pad(date.day)}`,
tag: this.folderToTag(folder),
parsed: date
};
};
/**
* Split a string into harptos calendar compatible segments.
* This assumes files with the following format:
* - single day: 1498-ches-09 -> { year: 1498, month: 33, day: 9}
* - several days: 1498-tarsakh-09-11 -> { year: 1498, month: 34, day: 11}
* (This doesn't work for ranges that span special days or months)
* @param {string} string A date string
* @returns {Date} date object containing year, month, day
*/
this.splitDateString = (string) => {
if (string.contains("/")) {
const pos = string.lastIndexOf('/') + 1;
string = string.substring(pos);
}
string = string.replace('.md', '');
const segments = string.toLowerCase().split('-');
let day = Number(segments[2]);
// Find last day of range: 1499-mirtul-01-11
if (segments.length > 3) {
const lastDay = Number(segments[3]);
day = isNaN(lastDay) ? day : lastDay;
}
const month = this.monthSort(segments[1]);
return {
year: Number(segments[0]),
month: month,
monthName: this.monthName(month),
day: day
};
};
this.app = window.customJS.app;
console.log("loaded Campaign");
}
}
Error in user YAML: (<unknown>): mapping values are not allowed in this context at line 26 column 25
---
<%* const { Campaign } = window.customJS;
const title = await tp.system.prompt("Enter Name");
const lower = Campaign.toFileName(title);
console.log(title, lower);
await tp.file.rename(lower);
// get all tags once
const allTags = Campaign.allTags();

let regionTag = await Campaign.chooseTagOrEmpty(tp, allTags, 'region/');
let placeTag = await Campaign.chooseTagOrEmpty(tp, allTags, 'place/');
let groupTag = await Campaign.chooseTagOrEmpty(tp, allTags, 'group');
let tags = '';
if ( placeTag || groupTag || regionTag ) {
  tags = '\ntags:';
  if ( regionTag ) {
    tags += `\n- ${regionTag}`;
  }
  if ( placeTag ) {
    tags += `\n- ${placeTag}`;
  }
  if ( groupTag ) {
    tags += `\n- ${groupTag}`;
  }
}
console.log(tags);
const aliases = `aliases: ["Encounter: ${title}"]`;
-%>
<% aliases %>
encounter: new<% tags %>
---

<% title %>

%% Abstract %%

Ideas

  • .

Main NPCs

Error in user YAML: (<unknown>): mapping values are not allowed in this context at line 17 column 25
---
<%* const { Campaign } = window.customJS;
const title = await tp.system.prompt("Enter group name");
const lower = Campaign.toFileName(title);
console.log(title, lower);
await tp.file.rename(lower);

// get all tags once
const allTags = Campaign.allTags();

const group = await Campaign.chooseTag(tp, allTags, 'group/', 'group');
const typeTag = await Campaign.chooseTag(tp, allTags, 'type/group', 'type/group');

const groupTag = `${group}/${lower}`;

const tags = 'tags:';
const jsengine = 'js-engine';
const aliases = `aliases: ["${title}"]`;
-%>
<% aliases %>
<% tags %>
- <% groupTag %>
- <% typeTag %>
---

<% title %>

{{short description}}

TL;DR

Beliefs

  1. ..
  2. ..
  3. ..

More...

  • Alignment
  • Allegiances
  • Enemies

Locations NPCs History References

Locations

const { Reference } = await window.cJS();
return Reference.itemsForTag(engine, '#<% groupTag %>', 'location');

NPCs

const { Reference } = await window.cJS();
return Reference.itemsForTag(engine, '#<% groupTag %>', 'npc');

History

const { Reference } = await window.cJS();
return Reference.logs(engine,'#<% groupTag %>'));

References

Error in user YAML: (<unknown>): mapping values are not allowed in this context at line 29 column 25
---
<%* const { Campaign } = window.customJS;
const title = await tp.system.prompt("Enter Name");
const lower = Campaign.toFileName(title);
const folder = await Campaign.chooseFolder(tp, tp.file.folder(true));
console.log(title, lower, folder);

await tp.file.move(`${folder}/${lower}`);

// get all tags once
const allTags = Campaign.allTags();

const place = await Campaign.chooseTag(tp, allTags, 'place/', 'place');
const placeTag = `${place}/${lower}`;

const typeTag = await Campaign.chooseTag(tp, allTags, 'type/location', 'type/location/shop');
const groupTag = await Campaign.chooseTagOrEmpty(tp, allTags, 'group/');
const regionTag = await Campaign.chooseTag(tp, allTags, 'region/', 'region/sword-coast-north');
console.log(typeTag, groupTag, regionTag, placeTag);

let tags = 'tags:';
tags += `\n- ${typeTag}`;
tags += `\n- ${placeTag}`;
if ( groupTag ) {
    tags += `\n- ${groupTag}`;
}
tags += `\n- ${regionTag}`;

const jsengine = 'js-engine';
const aliases = `aliases: ["${title}"]`;
-%>
<% aliases %>
<% tags %>
---

<% title %>

{{placeType}}, {{region}}

TL;DR description

  • Owner
  • Location

NPCs History

NPCs

const { Reference } = await window.cJS();
return Reference.itemsForTag(engine, '#<% placeTag %>', 'npc');

History

const { Reference } = await window.cJS();
return Reference.logs(engine,'#<% placeTag %>'));
class HeistSummaries {
constructor() {
this.SUMMARIES = /([\s\S]*?<!--indexOf SUMMARIES-->)[\s\S]*?(<!--indexOf END SUMMARIES-->[\s\S]*?)/i;
this.targetFile = "heist/all-summaries.md";
this.campaign = () => window.customJS.Campaign;
this.renderSummaries = async (file, renderer) => {
await this.app.vault.process(file, (source) => {
const match = this.SUMMARIES.exec(source);
if (match) {
source = match[1];
source += renderer();
source += match[2];
}
return source;
});
};
this.summaryEventSpan = (match, suffix = '') => {
const text = match[1];
const date = text.replace(/.*data-date=['"](.*?)-\d{2}['"].*/g, '$1');
let name = text.contains('data-name="')
? text.replace(/.*data-name="(.*?)".*/g, '$1')
: text.replace(/.*data-name='(.*?)'.*/g, '$1');
if (!name.endsWith('.') && !name.endsWith('!')) {
name += '.';
}
let data = match[2].trim();
if (data.length > 0 && !data.endsWith('.') && !data.endsWith('!')) {
data += '.';
}
return `\`${date}\` *${name}* ${data} ${suffix}`;
};
this.app = window.customJS.app;
console.log("loaded HeistSummary renderer");
}
async invoke() {
const allSummaries = this.app.vault.getFileByPath(this.targetFile);
if (!allSummaries) {
console.log(`${this.targetFile} file not found`);
return;
}
const files = this.app.vault.getMarkdownFiles()
.filter(t => t.path.startsWith("heist/sessions")
&& !t.path.contains("sessions.md")
&& !t.path.includes("encounter"))
.sort((a, b) => a.path.localeCompare(b.path));
const promises = files
.map(file => this.app.vault.cachedRead(file)
.then(txt => {
return {
file: file,
cache: this.app.metadataCache.getFileCache(file),
text: txt
};
}));
const data = await Promise.all(promises);
await this.renderSummaries(allSummaries, () => {
const result = ['\n'];
for (const d of data) {
const summary = d.cache.headings.find((h) => h.heading === "Summary");
const blockHeadingIndex = d.cache.headings.indexOf(summary);
let txt = d.text;
const start = summary.position.end.offset;
let endNum = summary.position.end.offset;
for (const h of d.cache.headings.slice(blockHeadingIndex + 1)) {
if (h.level <= summary.level) {
endNum = h.position.start.offset;
break;
}
}
if (endNum - start > 30) {
txt = txt.slice(start, endNum);
txt = txt.replace(/%%.*?%%/, '').trim();
}
result.push(`\n## [${d.file.name}](${d.file.path})\n`);
result.push(txt.replace(this.campaign().eventRegexp, (match, p1, p2) => this.summaryEventSpan([match, p1, p2])));
result.push('\n');
}
return result.join('\n');
});
}
}

<%* const { Campaign } = window.customJS; const title = await tp.system.prompt("Enter Name"); const lower = Campaign.toFileName(title); const folder = await Campaign.chooseFolder(tp, tp.file.folder(true)); console.log(title, lower, folder);

await tp.file.move(${folder}/${lower});

// get all tags once const allTags = Campaign.allTags();

const groupTag = await Campaign.chooseTagOrEmpty(tp, allTags, 'group'); const placeTag = await Campaign.chooseTagOrEmpty(tp, allTags, 'place/'); const regionTag = await Campaign.chooseTagOrEmpty(tp, allTags, 'region/');

const campaign = folder.contains("witchlight") ? 'witchlight' : 'heist';

const tags = 'tags: '; let moretags = ''; if ( placeTag ) { moretags += \n- ${placeTag}; } if ( groupTag ) { moretags += \n- ${groupTag}; } if ( regionTag ) { moretags += \n- ${regionTag}; } console.log(tags); const jsengine = 'js-engine'; const aliases = aliases: ["${title}"]; -%> <% aliases %> <% tags %>

  • type/npc
  • <% campaign %>/iff/unknown
  • <% campaign %>/npc/alive<% moretags %>

<% title %>

{{primary location}}

TL;DR description / personality / motivation

[!npc] <% title %> {{gender}} {{race}} {{role/occupation}}, {{alignment}}

  • Trait
  • Ideal
  • Bond
  • Flaw ^npc

Details Relationships Secrets Related

Details

Relationships

Organization or Faction

Secrets

References

const { Reference } = await window.cJS();
return Reference.linked(engine);
Error in user YAML: (<unknown>): mapping values are not allowed in this context at line 11 column 7
---
<%* 
const { Campaign } = window.customJS;
const folder = await Campaign.chooseFolder(tp, tp.file.folder(true));
const result = await Campaign.nextSession(tp, 3, folder);
console.log(result);
await tp.file.move(`${folder}/${result.nextName}`);
const span = 'span>'
tR += 'tags:' %>
- timeline
- <% result.tag %>/events/pc
played:
---

Session <% next %>: ...

%%prevnext%%

Summary

<<% span %> data-date='1499-xx-xx-00' data-category='<% result.tag %>' data-name="..."></<% span %>


Housekeeping

Recap

<%* tR += (result.prev ? ![invisible-embed](${result.prevFile}#Summary) : ''); %>

Onward...

%%

  • Objective single sentence: what is this session about?
  • Twist some fact that adds depth/complexity to the objective.
  • Opposition (who/what, motivation) %%

The Party

NPCs

Strong start

%% Kick off the session: What is happening? What's the point? What seed will move the story forward? Where is the action? (start as close to the action as you can) %%

Potential Scenes

  • .
    collapse: closed
    
  • .
    collapse: closed
    
  • .
    collapse: closed
    

Secrets and Clues

%% 10! single sentence containing previously unknown information that matters to PCs. Discovery will be improvised. Not all will be. Secrets are only real once they are shared. %%

  1. [ ]
  2. [ ]
  3. [ ]
  4. [ ]
  5. [ ]
  6. [ ]
  7. [ ]
  8. [ ]
  9. [ ]
  10. [ ]

Loot

  • [ ]
  • [ ]

Log / Detail

class Timeline {
constructor() {
this.RENDER_TIMELINE = /([\s\S]*?<!--TIMELINE BEGIN-->)[\s\S]*?(<!--TIMELINE END-->[\s\S]*?)/i;
this.campaign = () => window.customJS.Campaign;
this.event_codes = () => this.campaign().EVENT_CODES;
this.utils = () => window.customJS.Utils;
this.renderTimeline = async (file, renderer) => {
await this.app.vault.process(file, (source) => {
const match = this.RENDER_TIMELINE.exec(source);
if (match) {
source = match[1];
source += renderer();
source += match[2];
}
return source;
});
};
this.groupByYear = (API, events) => {
const years = [];
const groups = events.reduce((acc, event) => {
var _a;
const year = Number((_a = event.date) === null || _a === void 0 ? void 0 : _a.year);
if (isNaN(year) || !year) { // Filter out recurring events
return acc;
}
if (!acc[year]) {
acc[year] = [];
years.push(year);
}
acc[year].push(event); // Add the event to its corresponding group
return acc;
}, {});
years.sort();
// Create a new collection in sorted order by year
const sortedGroups = years.reduce((acc, key) => {
groups[key].sort(API.compareEvents); // Sort the values
acc[key] = groups[key]; // Add the sorted group to the result
return acc;
}, {});
return sortedGroups;
};
this.groupByEmoji = (API, events) => {
const emoji = events.reduce((acc, event) => {
// for each event
this.event_codes().forEach(e => {
// for each emoji
if (event.name.contains(e)) {
acc[e] = acc[e] || [];
acc[e].push(event);
}
});
return acc;
}, {});
Object.values(emoji).forEach(events => {
events.sort(API.compareEvents);
});
return emoji;
};
this.list = (groups, level, key, description) => {
let result = '';
const group = groups[key];
if (group) {
result += `${"#".repeat(level)} ${key} ${description}\n`;
result += "\n";
groups[key].forEach((e) => {
result += `- ${this.eventText(e)}\n`;
});
result += "\n";
}
return result;
};
/**
* @param {CalEvent} event
* @returns {string} HTML span with event display data
* @see punctuate
* @see harptosEventDate
*/
this.eventText = (event) => {
const name = this.punctuate(event.name);
const date = this.harptosEventDate(event);
const data = this.punctuate(event.description);
const suffix = event.note ? ` [➹](${event.note})` : '';
return `<span data-timeline="${event.sort.timestamp}">\`${date}\` *${name}* ${data}${suffix}</span>`;
};
/**
* @param {CalEvent} event
* @returns {string} Display date string for the Harptos event
* @see harptosZeroIndexMonth
* @see harptosDay
*/
this.harptosEventDate = (event) => {
const month = this.harptosZeroIndexMonth(event);
const day = this.harptosDay(event);
return `${event.date.year}-${month}${day}`;
};
/**
* Return the numeric day segment for a Harptos event.
* For special days (e.g. Midwinter, Shieldmeet), return an empty string.
* @param {CalEvent} event
* @returns {string} `-${day}` for non-special days, or an empty string for special days.
*/
this.harptosDay = (event) => {
switch (event.date.month) {
case 1:
case 5:
case 9:
case 12:
return '';
default:
return `-${event.date.day}`;
}
};
/**
* Map a zero-indexed Calendarium month to a Harptos month name.
* @param {CalEvent} event
* @returns {string} Month name
*/
this.harptosZeroIndexMonth = (event) => {
switch (event.date.month) {
case 0:
return 'Hammer';
case 1:
return 'Midwinter';
case 2:
return 'Alturiak';
case 3:
return 'Ches';
case 4:
return 'Tarsakh';
case 5:
return 'Greengrass';
case 6:
return 'Mirtul';
case 7:
return 'Kythorn';
case 8:
return 'Flamerule';
case 9:
if (event.date.day == 2) {
return 'Shieldmeet';
}
return 'Midsummer';
case 10:
return 'Elesias';
case 11:
return 'Eleint';
case 12:
return 'Highharvestide';
case 13:
return 'Marpenoth';
case 14:
return 'Uktar';
case 15:
return 'Feast of the Moon';
case 16:
return 'Nightal';
}
};
this.punctuate = (str) => {
str = str.trim();
if (!str.match(/.*[.!?]$/)) {
str += '.';
}
return str;
};
this.app = window.customJS.app;
console.log("loaded Timeline renderer");
}
async invoke() {
console.log("Render timelines");
const timeline = this.app.vault.getFileByPath("heist/all-timeline.md");
const groupedTimeline = this.app.vault.getFileByPath("heist/grouped-timeline.md");
const HeistAPI = window.Calendarium.getAPI("Heist");
const events = HeistAPI.getEvents();
console.log(events);
const groupByYear = this.groupByYear(HeistAPI, events);
await this.renderTimeline(timeline, () => {
let result = '\n';
for (const year of Object.keys(groupByYear)) {
result += this.list(groupByYear, 2, year, '');
}
return result;
});
const emoji = this.groupByEmoji(HeistAPI, events);
await this.renderTimeline(groupedTimeline, () => {
let result = '\n';
result += this.list(emoji, 2, 'πŸ“°', "Set up");
result += '\n';
result += this.list(emoji, 3, "πŸ—£οΈ", "Dagult Neverember");
result += this.list(emoji, 3, "🐲", "Aurinax");
result += this.list(emoji, 3, "😡", "Dalahkar's Trail");
result += this.list(emoji, 3, "πŸ—Ώ", "Where is the Stone?");
result += this.list(emoji, 2, "🧡", "Central thread");
result += '\n';
result += '## Allied Factions\n';
result += '\n';
result += this.list(emoji, 3, "βš”οΈ", "Doom Raiders");
result += this.list(emoji, 3, "πŸͺ¬", "Force Grey");
result += this.list(emoji, 3, "🎻", "Harpers");
result += '\n';
result += '## Opposing Factions\n';
result += '\n';
result += this.list(emoji, 3, "🧝🏿", "Bregan D'aerthe");
result += this.list(emoji, 3, "πŸ‘Ί", "Cassalanters");
result += this.list(emoji, 3, "πŸ’ƒ", "Gralhund's (Cassalanters / Zhenterim)");
result += this.list(emoji, 3, "🦹", "Manshoon Clone / Zhenterim");
result += this.list(emoji, 3, "πŸ‘Ύ", "Xanathar Guild");
result += '\n';
result += '## Nusiances\n';
result += '\n';
result += this.list(emoji, 3, 'πŸ₯Έ', 'Emmek Frewn');
result += this.list(emoji, 3, "πŸ€", "Shard Shunners");
result += '\n';
result += '## Other factions and actors\n';
result += '\n';
result += this.list(emoji, 3, "🌿", "Emerald Enclave");
result += this.list(emoji, 3, "🏰", "Lords' Alliance");
result += this.list(emoji, 3, "🌹", "Order of the Gauntlet");
result += this.list(emoji, 3, "πŸ§™β€β™€οΈ", "Watchful Order");
return result;
});
}
}
Error in user YAML: (<unknown>): mapping values are not allowed in this context at line 12 column 5
---
<%*
const { Campaign } = window.customJS;
const initial = await Campaign.nextHarptosDay(tp);
const dateString = await tp.system.prompt("Enter date", initial.date);
const result = Campaign.harptosDay(dateString);
console.log(initial, result);
await tp.file.rename(result.filename);
const jsengine = 'js-engine';
tR += 'tags:' %>
- timeline
- heist/events/npc
sort: <%* result.sort %>
---

<% result.heading %>

%% weather %%

Tavern Time

Rowen Coral KW Tavern Result
^tavern-time

NPC Activity

  • Emmek Frewn's mood: <%* tR += await Campaign.mood() %>

Bregan D'aerthe

  • Jarlaxle's mood: <%* tR += await Campaign.mood() %>
  • Sea Maiden's Faire: <%* tR += - On the docks: ${await Campaign.faire('buskers')}, ${await Campaign.faire('animals')}\n if (result.date.day % 2 == 0) { tR += ' - No Carnival tonight' } else { tR += ' - Carnival tonight' } %>

Xanathar Guild

  • Xanathar's mood: <%* tR += ${await Campaign.mood()}, ${await Campaign.mood()}, ${await Campaign.mood()} %>

Manshoon's Zhenterim

  • Manshoon's mood: <%* tR += await Campaign.mood() %>
  • Urstul Floxin's mood: <%* tR += await Campaign.mood() %>

The Doom Raiders

  • Davil Starsong's mood: <%* tR += await Campaign.mood() %>
  • Yagra's mood: <%* tR += await Campaign.mood() %>
  • Istrid Horn's mood: <%* tR += await Campaign.mood() %>

Sessions

const { Reference } = await window.cJS();
return Reference.log(engine);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment