import * as Handlebars from 'handlebars'; import { App, Vault, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TAbstractFile, TFile } from 'obsidian'; // Remember to rename these classes and interfaces! interface P2HLOSettings { userToken: string; campaignToken: string; notesFolder: string; } const DEFAULT_SETTINGS: P2HLOSettings = { userToken: '', campaignToken: '', notesFolder: '/', } const toolName = "p2hlo-bridge" const SYMBOLS = { FREE: "⭓", ACTION1: "⬻", ACTION2: "⬺", ACTION3: "⬽", REACTION: "⬲" } const Endpoints = { 'acquire-access-token' : "https://api.herolab.online/v1/access/acquire-access-token", 'get-stage': "https://api.herolab.online/v1/campaign/get-stage", 'get-bulk': "https://api.herolab.online/v1/character/get-bulk" } interface convertReturn { output: string; name: string; } export default class P2HLO extends Plugin { settings: P2HLOSettings; notes: { // mapping of a note by full filepath including file extension to its aliases [filePath: string]: string[], } private byPrefixCode(obj: object, prefix: string) { return obj[Object.keys(obj).find( predicate => predicate.startsWith(prefix))] } private filterKeysByPrefixCode(obj: object, prefix: string) { const keys = [...Object.keys(obj).filter( predicate => predicate.startsWith(prefix))] return keys.map (key => { return { ...obj[key] } }) } private prefixNumber(n: number) { if (n < 0) { return `-${n}` } else if (n > 0) { return `+${n}` } else { return "+0" } } private hloTextToMarkdown(str: string) { return str.replace(/\{b\}/gi, "**").replace(/\{\/b\}/gi, "**").replace(/\{br\}\{br\}/gi, "{br}").replace(/\{br\}/gi, "\\n") } private makeLink(linkName: string, category: string = ""): string|undefined { const sanitizedLinkName = linkName.replace("'", "'").toLowerCase().replace(/\ /g, "-") console.log("finding link to ", sanitizedLinkName) let found = undefined const reName = new RegExp(`^${sanitizedLinkName}(?:-.{1,4})?\.md$`, "gi") console.log("try to match", reName, category) let fileName = "" for (fileName in this.notes) { if ((this.notes[fileName]??"").contains(linkName)) { break; } } if (fileName !== "") { return fileName.split(".md")[0] } else { return undefined } } private convert(json: Object): convertReturn|null { const actor = this.byPrefixCode(json, "actor"); const name = actor["name"]; if (actor == undefined) { console.error("\tskipping unknown") return null } const characterData = actor["gameValues"]; const gameStatistics = actor["items"]; const perception = this.byPrefixCode(gameStatistics, "Perception"); const actions = this.filterKeysByPrefixCode(gameStatistics, "ab") const skills = this.filterKeysByPrefixCode(gameStatistics,"sk") const feats = this.filterKeysByPrefixCode(gameStatistics,"ft") const armorClass = this.byPrefixCode(gameStatistics, "ac") const clazz = this.filterKeysByPrefixCode(gameStatistics, "cl") const gear = this.filterKeysByPrefixCode(gameStatistics,"gr") const alchemicalItems = this.filterKeysByPrefixCode(gameStatistics,"ai") const ancestry = this.byPrefixCode(gameStatistics, "an") const languages = this.filterKeysByPrefixCode(gameStatistics,"ln") const naturalWeapons = this.filterKeysByPrefixCode(gameStatistics,"nw") const resources = this.filterKeysByPrefixCode(gameStatistics,"rv") const savingThrows = this.filterKeysByPrefixCode(gameStatistics,"sv") const weapons = this.filterKeysByPrefixCode(gameStatistics,"wp") const focusSpells = this.filterKeysByPrefixCode(gameStatistics, "fs") const spells = this.filterKeysByPrefixCode(gameStatistics, "sp") // gathering done, now we print const src = ` \`\`\`statblock columns: 2 forcecolumns: true layout: Basic Pathfinder 2e Layout modifier: {{perception}} hp: {{hp}} ac: {{ac}} name: "{{character_name}}" size: {{size}} level: {{classes}} trait_03: "Player" trait_04: "{{ancestry}}" languages: "{{#languages}} {{this}}, {{/languages}}; " perception: - name: "Perception" desc: "Perception {{perception}}" skills: - name: "Skills" desc: "{{#skills}}__{{name}}__: {{mod}} (1d20{{mod}}); {{/skills}}" abilitiyMods: [{{str}}, {{con}}, {{dex}}, {{int}}, {{cha}}, {{wis}}] abilities_top: {{#feats}} - name: "[[{{link}}|{{name}}]]" desc: "{{actions}} {{desc}}" {{/feats}} abilities_bot: {{#actions}} - name: "[[{{link}}|{{name}}]]" desc: "{{actions}} {{desc}}" {{/actions}} armorclass: - name: AC desc: "{{ac}}; __Fort__: {{fortitude}} (1d20{{fortitude}}); __Ref__: {{reflex}} (1d20{{reflex}}); __Will__: {{will}} (1d20{{will}})" speed: "{{speeds}}" attacks: {{#weapons}} - name: "[[{{link}}|{{name}}]]" desc: "{{actions}} {{desc}}" {{/weapons}} \`\`\` ### Spells {{#spells}} - [[{{link}}|{{name}}]] {{/spells}} ### Inventory *Items* in containers takes three actions to retrieve. {{#items}} - {{name}} {{/items}} ` const template = Handlebars.compile(src) const data = { character_name: name, size: characterData["actSpace"], classes: characterData["actClassText"], ancestry: ancestry?.name ?? "", languages: languages.map(predicate => predicate.name), hp: this.byPrefixCode(gameStatistics, "rvHitPoints").rvCurrent, perception: this.prefixNumber(perception.stNet), ac: armorClass.stNet, str: this.prefixNumber(this.byPrefixCode(gameStatistics, "asStr").stAbScModifier), con: this.prefixNumber(this.byPrefixCode(gameStatistics, "asCon").stAbScModifier), dex: this.prefixNumber(this.byPrefixCode(gameStatistics, "asDex").stAbScModifier), int: this.prefixNumber(this.byPrefixCode(gameStatistics, "asInt").stAbScModifier), cha: this.prefixNumber(this.byPrefixCode(gameStatistics, "asCha").stAbScModifier), wis: this.prefixNumber(this.byPrefixCode(gameStatistics, "asWis").stAbScModifier), fortitude: this.prefixNumber(this.byPrefixCode(gameStatistics, "svFortitude").stNet), reflex: this.prefixNumber(this.byPrefixCode(gameStatistics, "svReflex").stNet), will: this.prefixNumber(this.byPrefixCode(gameStatistics, "svWill").stNet), skills: skills.map( predicate => { return { name: predicate.name, mod: this.prefixNumber(predicate.stNet), level: predicate.ProfLevel, } }).filter(predicate => predicate.mod != "+0"), feats: feats.map( predicate => { let desc = "" let name = "" let link = "" let actionpoints = "" if (predicate.name.split("(").length > 1) { name = predicate.name.split("(")[0].trim() } else { name = predicate.name } if (predicate.useInPlay) { desc = predicate.useInPlay } else if (predicate.name.split("(").length > 1) { desc = predicate.name.split("(")[1].split(")")[0] } link = this.makeLink(name.trim(), "feats") ?? "" if (predicate.actions) { actionpoints = predicate.actions actionpoints = actionpoints.replace("Action1", SYMBOLS.ACTION1) actionpoints = actionpoints.replace("Action2", SYMBOLS.ACTION2) actionpoints = actionpoints.replace("Action3", SYMBOLS.ACTION3) actionpoints = actionpoints.replace("Reaction", SYMBOLS.REACTION) actionpoints = actionpoints.replace("Free", SYMBOLS.FREE) } return { name: name, desc: this.hloTextToMarkdown(desc), actions: actionpoints, link: link, } }), actions: actions.map(predicate => { let desc = "" let name = "" let actionpoints = "" let link = "" if (predicate.name.split("(").length > 1) { name = predicate.name.split("(")[0].trim() } else { name = predicate.name } if (predicate.useInPlay) { desc = predicate.useInPlay } else if (predicate.name.split("(").length > 1) { desc = predicate.name.split("(")[1].split(")")[0] } link = this.makeLink(name.trim(), "actions") ?? "" if (predicate.actions) { actionpoints = predicate.actions actionpoints = actionpoints.replace("Action1", SYMBOLS.ACTION1) actionpoints = actionpoints.replace("Action2", SYMBOLS.ACTION2) actionpoints = actionpoints.replace("Action3", SYMBOLS.ACTION3) actionpoints = actionpoints.replace("Reaction", SYMBOLS.REACTION) actionpoints = actionpoints.replace("Free", SYMBOLS.FREE) } return { name: name, desc: this.hloTextToMarkdown(desc), actions: actionpoints, link: link } }), weapons: [...weapons, ...naturalWeapons].map(predciate => { return { name: predciate.name, desc: this.hloTextToMarkdown(predciate.useInPlay), actions: SYMBOLS.ACTION1, link: `compendium/equipment/items/${predciate.name}` } }), items: [...alchemicalItems, ...gear].map(predicate => { let itemName = predicate.name if (predicate.items) { itemName += "(" itemName += Object.values(predicate.items).map(predicate2 => "*" + predicate2.name + "*").join(", ") itemName += ")" } itemName = itemName.replace(/'/g, "'") return { name: itemName } }), spells: [...focusSpells, ...spells].map(predicate => { let actionpoints = "" if (predicate.actions) { actionpoints = predicate.actions actionpoints = actionpoints.replace("Action1", SYMBOLS.ACTION1) actionpoints = actionpoints.replace("Action2", SYMBOLS.ACTION2) actionpoints = actionpoints.replace("Action3", SYMBOLS.ACTION3) actionpoints = actionpoints.replace("Reaction", SYMBOLS.REACTION) actionpoints = actionpoints.replace("Free", SYMBOLS.FREE) } let desc = "" if (predicate.useInPlay) { desc = this.hloTextToMarkdown(predicate.useInPlay) } console.log(name.trim()) let link = this.makeLink(predicate.name.trim(), "spells") ?? "" link = link.split(" (")[0] link = link.replace(/\ /g, "-") let level = predicate.spLevelBase return { name: predicate.name, actions: actionpoints, link, level, desc, } }) , speeds : this.byPrefixCode(gameStatistics, "mvSpeed")?.stNet ?? 0, } const output = template(data) return { output, name } } async fetchAccessToken() { const { accessToken } = await fetch(Endpoints['acquire-access-token'], { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, method: "POST", body: JSON.stringify({ refreshToken: this.settings.userToken, toolName, lifespan: 0 }) }).then( response => response.json() ) return accessToken } async fetchStage(accessToken: String): Promise { return new Promise( async (resolve, reject) => { if (accessToken) { const {wait, castList} = await fetch(Endpoints['get-stage'], { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, method: "POST", body: JSON.stringify({ accessToken: accessToken, campaignToken: this.settings.campaignToken }) }).then( response => response.json() ) if (wait != 0) { await new Promise( resolve1 => setTimeout( resolve1, wait)) } resolve(castList as String[]) } reject() }) } async fetchCharacters(accessToken: String, castList: String[]): Promise { return new Promise( async (resolve, reject) => { if (accessToken && castList.length > 0) { const individualCharacters: any = castList.map( castId => { return { elementToken: this.settings.campaignToken, castId: castId } }) const {wait, characters} = await fetch(Endpoints['get-bulk'], { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, method: "POST", body: JSON.stringify({ elementToken: this.settings.campaignToken, accessToken: accessToken, characters: individualCharacters }) }).then( response => response.json() ) if (wait != 0) { await new Promise( resolve1 => setTimeout( resolve1, wait)) } resolve(characters) } reject() }) } async preflightTest() { if (this.settings.userToken == '' || this.settings.campaignToken == '') { return false } return true } async performTask() { await this.precacheNotes() console.log(this.notes) if (await this.preflightTest()) { const accessToken = await this.fetchAccessToken() const castMember = await this.fetchStage(accessToken) if (castMember.length == 0) { new Notice("No Castmember found on Stage. Did you forgot to start a session?") return } const characters = await this.fetchCharacters(accessToken, castMember) try { const folder = this.app.vault.getFolderByPath(this.settings.notesFolder) await this.app.fileManager.trashFile(folder as TAbstractFile) } catch (e) { console.error(e) } try { await this.app.vault.createFolder(this.settings.notesFolder) } catch (e) { } for (let character of characters) { const result = this.convert(character.export.actors) if (result != null) { try { await this.app.vault.create(`${this.settings.notesFolder}/${result.name}.md`, result.output) } catch (e) { const directoryWithoutRoot = this.settings.notesFolder.substring(1) const file = this.app.vault.getFileByPath(`${directoryWithoutRoot}/${result.name}.md`) try { if (file) { await this.app.vault.modify(file, result.output) } else { console.error(file) } } catch (e) { console.error(e) } } } } new Notice("Import completed") } } async getAliasesOfTFile(fileObject: TFile): Promise { return new Promise( (resolve, reject) => { this.app.fileManager.processFrontMatter( fileObject, (frontMatter) => { resolve(frontMatter.aliases) } ) }) } private async precacheNotes() { this.notes = {} this.app.vault .getMarkdownFiles() .forEach( async (fileObject: TFile) => { this.notes[fileObject.path] = await this.getAliasesOfTFile(fileObject) } ) } async onload() { await this.loadSettings() // This creates an icon in the left ribbon. const ribbonIconEl = this.addRibbonIcon('dot-network', 'Sync Stage', async (evt: MouseEvent) => { // Called when the user clicks the icon. await this.performTask() }) // This adds a simple command that can be triggered anywhere this.addCommand({ id: 'sync-herolabonline-stage', name: 'Sync Stage', callback: () => { this.preflightTest() } }) this.addSettingTab(new P2HLOSettingTab(this.app, this)) } onunload() { } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } } class P2HLOSettingTab extends PluginSettingTab { plugin: P2HLO; constructor(app: App, plugin: P2HLO) { super(app, plugin); this.plugin = plugin; } display(): void { const {containerEl} = this; containerEl.empty(); new Setting(containerEl) .setName('User Token') .setDesc('enter your user token from your herolab.online apprentice or higher account') .addText(text => text .setPlaceholder('User Token') .setValue(this.plugin.settings.userToken) .onChange(async (value) => { this.plugin.settings.userToken = value; await this.plugin.saveSettings(); })); new Setting(containerEl) .setName('Campaign Token') .setDesc('enter the Element Token of your Campaign you want to get the characters from') .addText(text => text .setPlaceholder('element token') .setValue(this.plugin.settings.campaignToken) .onChange(async (value) => { this.plugin.settings.campaignToken = value; await this.plugin.saveSettings(); })); new Setting(containerEl) .setName('Character note folder') .setDesc('enter the path where the character notes should be placed in') .addText(text => text .setPlaceholder('/') .setValue(this.plugin.settings.notesFolder) .onChange(async (value) => { this.plugin.settings.notesFolder = value; await this.plugin.saveSettings(); })); } }