pf2e-herolab-bridge/main.ts

595 lines
16 KiB
TypeScript

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("&#x27;", "'").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(/&#x27;/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<String[]> {
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<Object[]> {
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<string[]> {
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();
}));
}
}