├── versions.json ├── .git-blame-ignore-revs ├── src ├── utils │ ├── index.ts │ ├── constants.ts │ ├── creature.ts │ ├── suggester.ts │ └── icons.ts ├── svelte │ ├── store.ts │ ├── Status.svelte │ ├── Table.svelte │ ├── Controls.svelte │ ├── Encounter.svelte │ ├── Create.svelte │ ├── App.svelte │ └── Creature.svelte ├── main.css ├── main.ts ├── settings.ts └── view.ts ├── .gitattributes ├── assets └── encounter.PNG ├── .gitignore ├── manifest.json ├── tsconfig.json ├── package.json ├── webpack.config.js ├── .github └── workflows │ └── release.yml ├── @types └── index.d.ts ├── styles.css ├── README.md └── LICENSE /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.3.0": "0.12.10" 3 | } 4 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | #Prettier 2 | bd4315a5902d7afc8717f29ebeede1a2fea9b60c 3 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./constants"; 2 | export * from "./icons"; 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /assets/encounter.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beaurancourt/obsidian-generic-initiative-tracker/HEAD/assets/encounter.PNG -------------------------------------------------------------------------------- /src/svelte/store.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | import type TrackerView from "../view"; 3 | 4 | export const view = writable(); 5 | export default { view }; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | 8 | # build 9 | main.js 10 | *.js.map 11 | dist 12 | styles.css 13 | 14 | # obsidian 15 | data.json 16 | .DS_Store 17 | rollup.config-dev.js 18 | webpack.dev.js 19 | 20 | #terminal 21 | .envrc 22 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "generic-initiative-tracker", 3 | "name": "Generic Initiative Tracker", 4 | "version": "1.3.0", 5 | "minAppVersion": "0.12.10", 6 | "author": "Beau Shinkle", 7 | "description": "TTRPG Generic Initiative Tracker for Obsidian.md", 8 | "authorUrl": "https://github.com/beaushinkle/obsidian-generic-initiative-tracker", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "es6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "types": ["svelte", "node"], 14 | "lib": ["dom", "esnext", "scripthost", "es2015"] 15 | }, 16 | "include": ["src/*"] 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import type { InitiativeTrackerData } from "@types"; 2 | 3 | export const INTIATIVE_TRACKER_VIEW = "initiative-tracker-view"; 4 | 5 | export const MIN_WIDTH_FOR_HAMBURGER = 300; 6 | 7 | export const DEFAULT_UNDEFINED = "–"; 8 | 9 | export const DEFAULT_SETTINGS: InitiativeTrackerData = { 10 | players: [], 11 | homebrew: [], 12 | version: null, 13 | canUseDiceRoll: false, 14 | initiative: "1d20 + %mod%", 15 | sync: false, 16 | state: { 17 | creatures: [], 18 | state: false, 19 | current: null, 20 | name: null, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/svelte/Status.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 |
23 | {status.name} 24 |
25 |
26 | 27 | 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "initiative-tracker", 3 | "version": "1.3.0", 4 | "description": "TTRPG Generic Initiative Tracker for Obsidian.md", 5 | "main": "main.js", 6 | "scripts": { 7 | "build": "webpack" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "@babel/core": "^7.14.6", 14 | "@popperjs/core": "^2.9.2", 15 | "@rollup/plugin-commonjs": "^18.0.0", 16 | "@rollup/plugin-node-resolve": "^11.2.1", 17 | "@rollup/plugin-typescript": "^8.2.1", 18 | "@tsconfig/svelte": "^2.0.1", 19 | "@types/node": "^14.14.37", 20 | "babel-loader": "^8.2.2", 21 | "copy-webpack-plugin": "^9.0.1", 22 | "css-loader": "^5.2.6", 23 | "obsidian": "^0.12.11", 24 | "rollup": "^2.32.1", 25 | "rollup-plugin-css-only": "^3.1.0", 26 | "rollup-plugin-svelte": "^7.1.0", 27 | "svelte": "^3.38.3", 28 | "svelte-loader": "^3.1.2", 29 | "svelte-preprocess": "^4.7.3", 30 | "title-case": "^3.0.3", 31 | "ts-loader": "^9.2.3", 32 | "tslib": "^2.3.0", 33 | "typescript": "^4.2.4", 34 | "webpack": "^5.41.1", 35 | "webpack-cli": "^4.7.2", 36 | "webpack-node-externals": "^3.0.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const CopyPlugin = require("copy-webpack-plugin"); 3 | const sveltePreprocess = require("svelte-preprocess"); 4 | 5 | const isDevMode = process.env.NODE_ENV === "development"; 6 | 7 | module.exports = { 8 | entry: "./src/main.ts", 9 | output: { 10 | path: isDevMode ? process.env.DEV_MODE_PATH : path.resolve(__dirname, "."), 11 | filename: "main.js", 12 | libraryTarget: "commonjs", 13 | }, 14 | target: "node", 15 | mode: isDevMode ? "development" : "production", 16 | ...(isDevMode ? { devtool: "eval" } : {}), 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.tsx?$/, 21 | loader: "ts-loader", 22 | options: { 23 | transpileOnly: true, 24 | }, 25 | }, 26 | { 27 | test: /\.(svelte)$/, 28 | use: [ 29 | { loader: "babel-loader" }, 30 | { 31 | loader: "svelte-loader", 32 | options: { 33 | preprocess: sveltePreprocess({}), 34 | }, 35 | }, 36 | ], 37 | }, 38 | { 39 | test: /\.(svg|njk|html)$/, 40 | type: "asset/source", 41 | }, 42 | ], 43 | }, 44 | plugins: [ 45 | new CopyPlugin({ 46 | patterns: [ 47 | { from: "./manifest.json", to: "." }, 48 | { from: "./src/main.css", to: "./styles.css" }, 49 | ], 50 | }), 51 | ], 52 | resolve: { 53 | alias: { 54 | svelte: path.resolve("node_modules", "svelte"), 55 | "~": path.resolve(__dirname, "src"), 56 | src: path.resolve(__dirname, "src"), 57 | }, 58 | extensions: [".ts", ".tsx", ".js", ".svelte"], 59 | mainFields: ["svelte", "browser", "module", "main"], 60 | }, 61 | externals: { 62 | electron: "commonjs2 electron", 63 | obsidian: "commonjs2 obsidian", 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /src/svelte/Table.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
26 | {#if creatures.length} 27 |
28 |
29 | 30 | 31 | Name 32 | 33 | 34 | 35 |
36 | {#each creatures as creature} 37 | dispatch("update-hp", evt.detail)} 40 | on:tag={(evt) => dispatch("update-tags", evt.detail)} 41 | {show} 42 | {state} 43 | active={creatures[current] == creature} 44 | /> 45 | {/each} 46 |
47 | {:else} 48 |
49 |

Add a creature to get started!

50 | Players may be created in settings. 51 |
52 | {/if} 53 |
54 | 55 | 81 | -------------------------------------------------------------------------------- /src/utils/creature.ts: -------------------------------------------------------------------------------- 1 | import type { Condition, CreatureState, HomebrewCreature } from "@types"; 2 | import { DEFAULT_UNDEFINED } from "./constants"; 3 | 4 | function getId() { 5 | return "ID_xyxyxyxyxyxy".replace(/[xy]/g, function (c) { 6 | var r = (Math.random() * 16) | 0, 7 | v = c == "x" ? r : (r & 0x3) | 0x8; 8 | return v.toString(16); 9 | }); 10 | } 11 | 12 | export class Creature { 13 | name: string; 14 | modifier: string; 15 | hp: number; 16 | ac: number; 17 | note: string; 18 | enabled: boolean = true; 19 | max: number; 20 | player: boolean; 21 | status: Set = new Set(); 22 | private _initiative: number; 23 | source: string; 24 | id: string; 25 | constructor(creature: HomebrewCreature, initiative: number = 0) { 26 | this.name = creature.name; 27 | this.modifier = creature.modifier ?? ""; 28 | this._initiative = Number(initiative ?? 0); 29 | 30 | this.max = creature.hp ? Number(creature.hp) : undefined; 31 | this.ac = creature.ac ? Number(creature.ac) : undefined; 32 | this.note = creature.note; 33 | this.player = creature.player; 34 | 35 | this.hp = this.max; 36 | this.source = creature.source; 37 | 38 | this.id = creature.id ?? getId(); 39 | } 40 | get hpDisplay() { 41 | if (this.max) { 42 | return `${this.hp}/${this.max}`; 43 | } 44 | return DEFAULT_UNDEFINED; 45 | } 46 | 47 | get initiative() { 48 | return this._initiative; 49 | } 50 | set initiative(x: number) { 51 | this._initiative = Number(x); 52 | } 53 | 54 | *[Symbol.iterator]() { 55 | yield this.name; 56 | yield this.initiative; 57 | yield this.modifier; 58 | yield this.max; 59 | yield this.ac; 60 | yield this.note; 61 | yield this.id; 62 | } 63 | 64 | update(creature: HomebrewCreature) { 65 | this.name = creature.name; 66 | this.modifier = creature.modifier ?? ""; 67 | 68 | this.max = creature.hp ? Number(creature.hp) : undefined; 69 | 70 | if (this.hp > this.max) this.hp = this.max; 71 | 72 | this.ac = creature.ac ? Number(creature.ac) : undefined; 73 | this.note = creature.note; 74 | this.player = creature.player; 75 | 76 | this.source = creature.source; 77 | } 78 | 79 | toProperties() { 80 | return { ...this }; 81 | } 82 | 83 | toJSON(): CreatureState { 84 | return { 85 | name: this.name, 86 | initiative: this.initiative, 87 | modifier: this.modifier, 88 | hp: this.max, 89 | ac: this.ac, 90 | note: this.note, 91 | id: this.id, 92 | currentHP: this.hp, 93 | status: Array.from(this.status), 94 | enabled: this.enabled, 95 | player: this.player, 96 | }; 97 | } 98 | 99 | static fromJSON(state: CreatureState) { 100 | const creature = new Creature(state, state.initiative); 101 | creature.enabled = state.enabled; 102 | 103 | creature.hp = state.currentHP; 104 | creature.status = new Set(state.status); 105 | return creature; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build obsidian plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: generic-initiative-tracker # Change this to the name of your plugin-id folder 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: "14.x" # You might need to adjust this value to your own version 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build --if-present 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | ls 31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 32 | - name: Create Release 33 | id: create_release 34 | uses: actions/create-release@v1 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | VERSION: ${{ github.ref }} 38 | with: 39 | tag_name: ${{ github.ref }} 40 | release_name: ${{ github.ref }} 41 | draft: false 42 | prerelease: false 43 | - name: Upload zip file 44 | id: upload-zip 45 | uses: actions/upload-release-asset@v1 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | upload_url: ${{ steps.create_release.outputs.upload_url }} 50 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 51 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 52 | asset_content_type: application/zip 53 | - name: Upload main.js 54 | id: upload-main 55 | uses: actions/upload-release-asset@v1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | upload_url: ${{ steps.create_release.outputs.upload_url }} 60 | asset_path: ./main.js 61 | asset_name: main.js 62 | asset_content_type: text/javascript 63 | - name: Upload manifest.json 64 | id: upload-manifest 65 | uses: actions/upload-release-asset@v1 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | with: 69 | upload_url: ${{ steps.create_release.outputs.upload_url }} 70 | asset_path: ./manifest.json 71 | asset_name: manifest.json 72 | asset_content_type: application/json 73 | - name: Upload styles.css 74 | id: upload-css 75 | uses: actions/upload-release-asset@v1 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | with: 79 | upload_url: ${{ steps.create_release.outputs.upload_url }} 80 | asset_path: ./styles.css 81 | asset_name: styles.css 82 | asset_content_type: text/css 83 | -------------------------------------------------------------------------------- /src/svelte/Controls.svelte: -------------------------------------------------------------------------------- 1 | 85 | 86 |
87 |
88 | {#if state} 89 |
90 |
91 |
92 | {:else} 93 |
94 | {/if} 95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 | 103 | 125 | -------------------------------------------------------------------------------- /@types/index.d.ts: -------------------------------------------------------------------------------- 1 | import "obsidian"; 2 | import type Creature__SvelteComponent_ from "src/svelte/Creature.svelte"; 3 | import type { Creature } from "src/utils/creature"; 4 | 5 | // CUSTOM EVENTS 6 | // ------------------------ 7 | // Convert tuple to arguments of Event.on 8 | type OnArgs = T extends [infer A, ...infer B] 9 | ? A extends string 10 | ? [name: A, callback: (...args: B) => any] 11 | : never 12 | : never; 13 | export type TrackerEvents = 14 | | [name: "initiative-tracker:state-change", state: TrackerViewState] 15 | | [name: "initiative-tracker:players-updated", pcs: Creature[]] 16 | | [name: "initiative-tracker:creatures-added", npcs: Creature[]] 17 | | [ 18 | name: "initiative-tracker:creature-added-at-location", 19 | creature: Creature, 20 | latlng: L.LatLng 21 | ] 22 | | [name: "initiative-tracker:add-creature-here", latlng: L.LatLng] 23 | | [name: "initiative-tracker:creature-updated", creature: Creature] 24 | | [ 25 | name: "initiative-tracker:creature-updated-in-settings", 26 | creature: Creature 27 | ] 28 | | [name: "initiative-tracker:creatures-removed", npcs: Creature[]] 29 | | [name: "initiative-tracker:new-encounter", state: TrackerViewState] 30 | | [name: "initiative-tracker:reset-encounter", state: TrackerViewState] 31 | | [name: "initiative-tracker:active-change", creature: Creature] 32 | | [name: "initiative-tracker:unload"] 33 | | [name: "initiative-tracker:apply-damage", creature: Creature] 34 | | [name: "initiative-tracker:add-status", creature: Creature] 35 | | [ 36 | name: "initiative-tracker:enable-disable", 37 | creature: Creature, 38 | enable: boolean 39 | ] 40 | | [name: "initiative-tracker:remove", creature: Creature] 41 | | [name: "initiative-tracker:closed"] 42 | | [name: "initiative-tracker:should-save"] 43 | /** This event can be used to start an event by sending an object with a name, HP, AC, and initiative modifier at minimum. */ 44 | | [name: "initiative-tracker:start-encounter", creatures: HomebrewCreature[]]; 45 | 46 | export type EventsOnArgs = OnArgs; 47 | 48 | export interface TrackerViewState { 49 | state: boolean; 50 | current: number; 51 | npcs: Creature[]; 52 | pcs: Creature[]; 53 | creatures: Creature[]; 54 | } 55 | 56 | export interface Condition { 57 | name: string; 58 | description: string; 59 | path: string; 60 | } 61 | 62 | export interface InputValidate { 63 | input: HTMLInputElement; 64 | validate: (i: HTMLInputElement) => boolean; 65 | } 66 | 67 | export interface InitiativeTrackerData { 68 | players: HomebrewCreature[]; 69 | homebrew: HomebrewCreature[]; 70 | version: string; 71 | canUseDiceRoll: boolean; 72 | initiative: string; 73 | sync: boolean; 74 | state: InitiativeViewState; 75 | } 76 | 77 | export interface InitiativeViewState { 78 | creatures: CreatureState[]; 79 | state: boolean; 80 | current: number; 81 | name: string; 82 | } 83 | 84 | export interface CreatureState extends HomebrewCreature { 85 | status: Condition[]; 86 | enabled: boolean; 87 | currentHP: number; 88 | initiative: number; 89 | player: boolean; 90 | } 91 | 92 | export interface HomebrewCreature { 93 | name?: string; 94 | hp?: number; 95 | ac?: number; 96 | stats?: number[]; 97 | source?: string; 98 | cr?: number | string; 99 | modifier?: string; 100 | note?: string; 101 | player?: boolean; 102 | id?: string; 103 | } 104 | 105 | export type ability = 106 | | "strength" 107 | | "dexterity" 108 | | "constitution" 109 | | "intelligence" 110 | | "wisdom" 111 | | "charisma"; 112 | 113 | export type Spell = string | { [key: string]: string }; 114 | 115 | export interface Trait { 116 | name: string; 117 | desc: string; 118 | [key: string]: any; 119 | } 120 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | .initiative-tracker-add-player-modal .has-error { 2 | border-color: var(--background-modifier-error-hover); 3 | } 4 | 5 | .initiative-tracker-settings .initiative-tracker-additional-container { 6 | border-top: 1px solid var(--background-modifier-border); 7 | border-bottom: 0px solid var(--background-modifier-border); 8 | padding: 18px 0 0 0; 9 | background-color: inherit; 10 | } 11 | 12 | .initiative-tracker-additional-container .initiative-tracker-players { 13 | display: grid; 14 | grid-template-columns: 3fr 1fr 1fr 1fr auto; 15 | gap: 0.5rem; 16 | } 17 | 18 | .initiative-tracker-settings 19 | .initiative-tracker-additional-container 20 | .initiative-tracker-players 21 | .initiative-tracker-player { 22 | display: contents; 23 | } 24 | .initiative-tracker-settings 25 | .initiative-tracker-additional-container 26 | .initiative-tracker-players 27 | .initiative-tracker-player 28 | > *:not(:first-child) { 29 | text-align: center; 30 | } 31 | .initiative-tracker-settings 32 | .initiative-tracker-additional-container 33 | .initiative-tracker-players 34 | .initiative-tracker-player 35 | .initiative-tracker-player-icon { 36 | display: grid; 37 | grid-template-columns: auto auto; 38 | gap: 0.5rem; 39 | } 40 | .initiative-tracker-settings 41 | .initiative-tracker-additional-container 42 | .initiative-tracker-players 43 | .initiative-tracker-player.headers { 44 | font-weight: bolder; 45 | } 46 | .initiative-tracker-settings 47 | .initiative-tracker-additional-container 48 | .initiative-tracker-players 49 | .initiative-tracker-player.headers 50 | .clickable-icon { 51 | color: var(--text-normal); 52 | } 53 | .initiative-tracker-settings 54 | .initiative-tracker-additional-container 55 | .initiative-tracker-players 56 | .initiative-tracker-player.headers 57 | .clickable-icon:hover { 58 | color: var(--text-normal); 59 | } 60 | 61 | .initiative-tracker-settings .initiative-sync { 62 | border-top: 1px solid var(--background-modifier-border); 63 | margin-top: 18px; 64 | padding-top: 18px; 65 | } 66 | 67 | .initiative-tracker-settings .initiative-sync .initiative-synced { 68 | border-top: 0px; 69 | margin: 0 2rem; 70 | padding-top: 0; 71 | } 72 | .initiative-tracker-settings 73 | .initiative-sync 74 | .initiative-synced 75 | .setting-item-name { 76 | color: var(--interactive-success); 77 | display: grid; 78 | grid-template-columns: auto 1fr; 79 | align-items: center; 80 | gap: 0.5rem; 81 | } 82 | 83 | .initiative-tracker-settings .initiative-tracker-monster-filter { 84 | position: sticky; 85 | top: -5px; 86 | padding-top: 5px; 87 | background-color: inherit; 88 | z-index: 1; 89 | } 90 | 91 | .initiative-tracker-settings 92 | .initiative-tracker-file-upload 93 | > input[type="file"] { 94 | display: none; 95 | } 96 | 97 | .initiative-tracker-settings 98 | .initiative-tracker-additional-container 99 | > .additional { 100 | margin: 6px 12px; 101 | } 102 | .initiative-tracker-settings 103 | .initiative-tracker-additional-container 104 | > .additional 105 | > .setting-item { 106 | border-top: 0; 107 | padding-top: 9px; 108 | } 109 | .initiative-tracker-settings 110 | .initiative-tracker-additional-container 111 | > .additional 112 | > .setting-item 113 | > .setting-item-control 114 | > *:first-child { 115 | margin: 0 6px; 116 | } 117 | 118 | .tooltip.initiative-tracker-condition-tooltip { 119 | text-align: left; 120 | } 121 | 122 | .block-language-encounter .encounter-container { 123 | display: grid; 124 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 125 | } 126 | 127 | .initiative-tracker-settings .coffee { 128 | width: 60%; 129 | color: var(--text-faint); 130 | margin: 1rem auto; 131 | text-align: center; 132 | } 133 | .initiative-tracker-settings .coffee img { 134 | height: 30px; 135 | } 136 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .initiative-tracker-add-player-modal .has-error { 2 | border-color: var(--background-modifier-error-hover); 3 | } 4 | 5 | .initiative-tracker-settings .initiative-tracker-additional-container { 6 | border-top: 1px solid var(--background-modifier-border); 7 | border-bottom: 0px solid var(--background-modifier-border); 8 | padding: 18px 0 0 0; 9 | background-color: inherit; 10 | } 11 | 12 | .initiative-tracker-additional-container .initiative-tracker-players { 13 | display: grid; 14 | grid-template-columns: 3fr 1fr 1fr 1fr auto; 15 | gap: 0.5rem; 16 | } 17 | 18 | .initiative-tracker-settings 19 | .initiative-tracker-additional-container 20 | .initiative-tracker-players 21 | .initiative-tracker-player { 22 | display: contents; 23 | } 24 | .initiative-tracker-settings 25 | .initiative-tracker-additional-container 26 | .initiative-tracker-players 27 | .initiative-tracker-player 28 | > *:not(:first-child) { 29 | text-align: center; 30 | } 31 | .initiative-tracker-settings 32 | .initiative-tracker-additional-container 33 | .initiative-tracker-players 34 | .initiative-tracker-player 35 | .initiative-tracker-player-icon { 36 | display: grid; 37 | grid-template-columns: auto auto; 38 | gap: 0.5rem; 39 | } 40 | .initiative-tracker-settings 41 | .initiative-tracker-additional-container 42 | .initiative-tracker-players 43 | .initiative-tracker-player.headers { 44 | font-weight: bolder; 45 | } 46 | .initiative-tracker-settings 47 | .initiative-tracker-additional-container 48 | .initiative-tracker-players 49 | .initiative-tracker-player.headers 50 | .clickable-icon { 51 | color: var(--text-normal); 52 | } 53 | .initiative-tracker-settings 54 | .initiative-tracker-additional-container 55 | .initiative-tracker-players 56 | .initiative-tracker-player.headers 57 | .clickable-icon:hover { 58 | color: var(--text-normal); 59 | } 60 | 61 | .initiative-tracker-settings .initiative-sync { 62 | border-top: 1px solid var(--background-modifier-border); 63 | margin-top: 18px; 64 | padding-top: 18px; 65 | } 66 | 67 | .initiative-tracker-settings .initiative-sync .initiative-synced { 68 | border-top: 0px; 69 | margin: 0 2rem; 70 | padding-top: 0; 71 | } 72 | .initiative-tracker-settings 73 | .initiative-sync 74 | .initiative-synced 75 | .setting-item-name { 76 | color: var(--interactive-success); 77 | display: grid; 78 | grid-template-columns: auto 1fr; 79 | align-items: center; 80 | gap: 0.5rem; 81 | } 82 | 83 | .initiative-tracker-settings .initiative-tracker-monster-filter { 84 | position: sticky; 85 | top: -5px; 86 | padding-top: 5px; 87 | background-color: inherit; 88 | z-index: 1; 89 | } 90 | 91 | .initiative-tracker-settings 92 | .initiative-tracker-file-upload 93 | > input[type="file"] { 94 | display: none; 95 | } 96 | 97 | .initiative-tracker-settings 98 | .initiative-tracker-additional-container 99 | > .additional { 100 | margin: 6px 12px; 101 | } 102 | .initiative-tracker-settings 103 | .initiative-tracker-additional-container 104 | > .additional 105 | > .setting-item { 106 | border-top: 0; 107 | padding-top: 9px; 108 | } 109 | .initiative-tracker-settings 110 | .initiative-tracker-additional-container 111 | > .additional 112 | > .setting-item 113 | > .setting-item-control 114 | > *:first-child { 115 | margin: 0 6px; 116 | } 117 | 118 | .tooltip.initiative-tracker-condition-tooltip { 119 | text-align: left; 120 | } 121 | 122 | .block-language-encounter .encounter-container { 123 | display: grid; 124 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 125 | } 126 | 127 | .initiative-tracker-settings .coffee { 128 | width: 60%; 129 | color: var(--text-faint); 130 | margin: 1rem auto; 131 | text-align: center; 132 | } 133 | .initiative-tracker-settings .coffee img { 134 | height: 30px; 135 | } 136 | -------------------------------------------------------------------------------- /src/svelte/Encounter.svelte: -------------------------------------------------------------------------------- 1 | 74 | 75 |
76 |
77 |
78 |

{name}

79 |
80 |
81 | {#if players instanceof Array && players.length} 82 |
83 |

Players

84 |
    85 | {#each players as player} 86 |
  • 87 | {player} 88 |
  • 89 | {/each} 90 |
91 |
92 | {:else if !players} 93 |
94 |

No Players

95 |
96 | {/if} 97 | 98 |
99 |

Creatures

100 | {#if creatures.length} 101 |
    102 | {#each displayMap as [creature, count]} 103 |
  • 104 | {count} {creature.name} 105 |
  • 106 | {/each} 107 |
108 | {:else} 109 | No creatures 110 | {/if} 111 |
112 |
113 |
114 | 115 | 132 | -------------------------------------------------------------------------------- /src/svelte/Create.svelte: -------------------------------------------------------------------------------- 1 | 93 | 94 |
95 |
96 | 97 | 104 |
105 |
106 | 107 | 114 |
115 |
116 | 117 | 124 |
125 |
126 | 127 | 134 |
135 |
136 | 137 | 144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 | 152 | 178 | -------------------------------------------------------------------------------- /src/svelte/App.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 89 | 90 |
91 | 92 | {#if name && name.length} 93 |
94 |

{name}

95 |
96 | {/if} 97 | { 103 | updatingHP = evt.detail; 104 | }} 105 | on:update-tags={(evt) => { 106 | updatingStatus = evt.detail; 107 | }} 108 | /> 109 | {#if updatingHP} 110 |
111 | Apply damage(+) or healing(-): 112 | 113 | 140 |
141 | {:else if updatingStatus} 142 |
143 | Apply status: 144 | 145 | 177 |
178 | {:else} 179 |
180 | {#if addNew || addNewAsync} 181 | { 183 | addNew = false; 184 | addNewAsync = false; 185 | dispatch("cancel-add-new-async"); 186 | }} 187 | on:save={(evt) => { 188 | const creature = evt.detail; 189 | const newCreature = new Creature( 190 | { 191 | name: creature.name, 192 | hp: creature.hp, 193 | ac: creature.ac, 194 | modifier: creature.modifier, 195 | player: creature.player, 196 | }, 197 | creature.initiative 198 | ); 199 | if (addNewAsync) { 200 | dispatch("add-new-async", newCreature); 201 | } else { 202 | view.addCreatures(newCreature); 203 | } 204 | addNew = false; 205 | addNewAsync = false; 206 | }} 207 | /> 208 | {:else} 209 |
210 |
211 |
212 |
213 | {/if} 214 |
215 | {/if} 216 |
217 | 218 | 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TTRPG Generic Initiative Tracker for Obsidian.md 2 | 3 | This plugin can be used as an initiative tracker within Obsidian.md. 4 | 5 | When enabled, the plugin will add an additional view in the right pane, where 6 | players and creatures can be added to track their initiatives during combat. 7 | 8 | This is a trimmed-down fork of 9 | https://github.com/valentine195/obsidian-initiative-tracker which aims to 10 | separate out the iniative concepts from the DnD 5e specific stuff to make it 11 | system agnostic. 12 | 13 | ## Main Differences 14 | 15 | - No XP or Level settings 16 | - Dropped support for homebrew creatures 17 | - Dropped all of the 5e specific import functionality 18 | - Massively simplified the settings screen 19 | - Made the encounter renderer use internal links 20 | - Give multiple creatures with the same name a unique index 21 | - Initiative modifiers support dice strings (like 2d6) 22 | - Misc bugfixes 23 | 24 | ## Creating Encounters in Notes 25 | 26 | Encounters can be created and launched directly from notes using the "encounter" code block, like so: 27 | 28 | ```` 29 | ```encounter 30 | creatures: 31 | - 2: Fighter 1, 13, 11, 550120 32 | - 2: Fighter 2, 13, 10, 575120 33 | ``` 34 | ```` 35 | 36 | This will render like this in Preview: 37 | 38 | ![image](https://user-images.githubusercontent.com/1045160/145105220-4f920d03-c84a-4edd-984c-f139988d16e7.png) 39 | 40 | (gruvbox theme) 41 | 42 | Clicking on the button next to the encounter name will then launch the encounter in the Initiative Tracker. The names attempt to wikilink as though you had surrounded them with `[[{name}]]`. 43 | 44 | With players configured, that looks like: 45 | 46 | ![image](https://user-images.githubusercontent.com/1045160/145105397-546a7304-3c7f-4ffd-b74a-964381ebf0b5.png). 47 | 48 | On hover, you get both a preview of the internal note and also an initative summary: 49 | 50 | ![image](https://user-images.githubusercontent.com/1045160/145105854-86c74cc0-6106-4ed5-89cb-aa83a5f2626d.png) 51 | 52 | ### Parameters 53 | 54 | There are 3 parameters for each encounter, with more detail below. 55 | 56 | ```` 57 | ```encounter 58 | name: string # Name of the encounter. Optional. 59 | players: boolean | string | array # Which players to include. Optional. 60 | creatures: array # Array of creatures to include in the encounter. Optional. 61 | ``` 62 | ```` 63 | 64 | #### Name 65 | 66 | The name of the encounter, which will be displayed both in Preview mode as well as in the Initiative Tracker when the encounter is launched. 67 | 68 | #### Players 69 | 70 | The `players` parameter can be used to filter the players stored in settings before starting the encounter. 71 | 72 | If the `players` parameter is omitted, all players will be added to the encounter. 73 | 74 | ```` 75 | ```encounter 76 | players: false # No players will be added to the encounter. 77 | players: none # Same as players: false 78 | players: true # All players will be added. Same as omitting the parameter. 79 | players: # Players will only be added to the encounter if they match the provided names. 80 | - Name 81 | - Name 2 82 | ``` 83 | ```` 84 | 85 | #### Creatures 86 | 87 | The most complicated parameter, `creatures` may be used to add additional creatures to the encounter. 88 | 89 | The basic creature will be defined as an array with the syntax of `[name, hp, ac, initiative modifer]`. 90 | 91 | **Please note that in all cases, hp, ac, and the modifier are optional.** 92 | 93 | ```` 94 | ```encounter 95 | creatures: 96 | - My Monster # 1 monster named My Monster will be added, with no HP, AC or modifier. 97 | - Goblin, 7, 15, 2 # 1 goblin with HP: 7, AC: 15, MOD: 2 will be added. 98 | ``` 99 | ```` 100 | 101 | Multiple of the same creature may be added using `X: [name, hp, ac, initiative modifer]`, which will add `X` creatures: 102 | 103 | ```` 104 | ```encounter 105 | creatures: 106 | - 3: Goblin, 7, 15, 2 # 3 goblins with HP: 7, AC: 15, MOD: 2 will be added. 107 | ``` 108 | ```` 109 | 110 | You may _also_ add multiple creatures by simply adding additional lines; this will also allow you to change HP, AC and modifier values for different creatures: 111 | 112 | ```` 113 | ```encounter 114 | creatures: 115 | - 2: Goblin, 7, 15, 2 # 2 goblins with HP: 7, AC: 15, MOD: 2 will be added. 116 | - Goblin, 6, 15, 2 # 1 goblin with HP: 6, AC: 15, MOD: 2 will be added. 117 | - Goblin, 9, 15, 2 # 1 goblin with HP: 9, AC: 15, MOD: 2 will be added. 118 | - Hobgoblin, 20, 19, 2d+3 # 1 Hobgoblin with HP: 20, AC: 19: MOD: 2d+3 (rerolls each time) will be added 119 | ``` 120 | ```` 121 | 122 | ### Parameters 123 | 124 | ## Using the Initiative Tracker 125 | 126 | Monsters may be added to the combat by clicking the `Add Creature` button, which will open a form where the creature's name, HP, AC and initiative can be set. 127 | 128 | Once all of the creatures in a given combat have been added, initiatives can be modified by clicking on the initiative number and entering the new initiative. Names for non-player creatures can be modified in the same way. 129 | 130 | ### Actions 131 | 132 | Creatures may be disabled or removed from the combat, or statuses (such as "poisoned") may be added in the `Actions` menu on the right of each creature. 133 | 134 | ### Controls 135 | 136 | Combat can be started by clicking the `play` button. This will display the currently active creature. Clicking `next` or `previous` will move to the next enabled combatant. 137 | 138 | Initiatives can be re-rolled for all creatures in the combat by clicking the `Re-roll Initiatives` button. 139 | 140 | The creatures HP and status effects can be reset by clicking `Reset HP and Status`. 141 | 142 | A new encounter (just player characters) can be started by clicking `New Encounter`. 143 | 144 | ### Commands 145 | 146 | The plugin registers several commands to Obsidian that can be assigned to hotkeys or used with the Command Palette (Ctrl / Cmd + P). 147 | 148 | #### Open Initiative Tracker 149 | 150 | If the initiative tracker view has been closed for any reason, use this command to add it back to the right pane. 151 | 152 | #### Toggle Encounter 153 | 154 | This command can be used to start or stop an encounter. 155 | 156 | #### Next Combatant 157 | 158 | If the encounter is active, this command can be used to make the next enabled combatant active (similar to clicking the `Next` button). 159 | 160 | #### Previous Combatant 161 | 162 | If the encounter is active, this command can be used to make the previous enabled combatant active (similar to clicking the `Previous` button). 163 | 164 | # Settings 165 | 166 | The setting tab has options for adding and managing players and the ability to change the formula used to calculate the initiative. 167 | 168 | ## Players 169 | 170 | Players may be added in settings. Players created in this way will be automatically added to encounters. 171 | 172 | ## Initiative Formula 173 | 174 | > This setting can only be modified when the [Dice Roller](https://github.com/valentine195/obsidian-dice-roller) plugin is installed. 175 | 176 | This setting can be used to modify how a creature's initiative is calculated by the plugin. Use `%mod%` as a placeholder for the creature's initiative modifier. 177 | 178 | It defaults to `1d20 + %mod%`. 179 | 180 | This will support any dice formula supported by the Dice Roller plugin. 181 | 182 | # Custom Conditions 183 | 184 | The initiative plugin will dynamically populate conditions from notes tagged 185 | `#condition`. I'm still working on rendering the description text to look 186 | better, but it's functional for now. 187 | 188 | ![image](https://user-images.githubusercontent.com/1045160/149223266-25afdf23-85b6-4616-9399-3986ab63bf7f.png) 189 | ![image](https://user-images.githubusercontent.com/1045160/149223344-107a3d00-647d-4ce3-a965-7f0bc8fdc293.png) 190 | 191 | # Roadmap 192 | 193 | This is a list of features that are planned for the plugin. Some of these may or may not be developed. 194 | 195 | - Wikilink conditions 196 | - Add a setting to point to a conditions tag 197 | - Make the rendering for the conditions hover-over look better 198 | 199 | # Installation 200 | 201 | ## From within Obsidian 202 | 203 | From Obsidian v0.9.8, you can activate this plugin within Obsidian by doing the following: 204 | 205 | - Open Settings > Third-party plugin 206 | - Make sure Safe mode is **off** 207 | - Click Browse community plugins 208 | - Search for this plugin 209 | - Click Install 210 | - Once installed, close the community plugins window and activate the newly installed plugin 211 | 212 | ## From GitHub 213 | 214 | - Download the Latest Release from the Releases section of the GitHub Repository 215 | - Extract the plugin folder from the zip to your vault's plugins folder: `/.obsidian/plugins/` 216 | Note: On some machines the `.obsidian` folder may be hidden. On MacOS you should be able to press `Command+Shift+Dot` to show the folder in Finder. 217 | - Reload Obsidian 218 | - If prompted about Safe Mode, you can disable safe mode and enable the plugin. 219 | Otherwise head to Settings, third-party plugins, make sure safe mode is off and 220 | enable the plugin from there. 221 | 222 | ### Updates 223 | 224 | You can follow the same procedure to update the plugin 225 | 226 | # Warning 227 | 228 | This plugin comes with no guarantee of stability and bugs may delete data. 229 | Please ensure you have automated backups. 230 | 231 | # TTRPG plugins 232 | 233 | Check out valentine195's other plugins! 234 | 235 | - [Obsidian Leaflet](https://github.com/valentine195/obsidian-leaflet-plugin) - Add interactive maps to Obsidian.md notes 236 | - [Dice Roller](https://github.com/valentine195/obsidian-dice-roller) - Inline dice rolling for Obsidian.md 237 | - [5e Statblocks](https://github.com/valentine195/obsidian-5e-statblocks) - Format statblocks 5e-style 238 | -------------------------------------------------------------------------------- /src/svelte/Creature.svelte: -------------------------------------------------------------------------------- 1 | 119 | 120 |
121 | 122 | 123 | {#if state && active} 124 | 138 | {/if} 139 | 140 | 141 |
142 | 175 |
176 | 177 | {#if creature.player} 178 | {creature.name} 179 | {:else} 180 | {creature.name} 193 | {/if} 194 | 195 |
196 | { 199 | /* if (creature.hp) */ dispatch("hp", creature); 200 | }}>{creature.hpDisplay} 202 |
203 | 204 | {creature.ac ?? DEFAULT_UNDEFINED} 205 |
206 |
207 |
208 | {#if creature.enabled} 209 |
214 | {:else} 215 |
220 | {/if} 221 |
222 |
223 | 224 | 225 | 226 | 227 |
228 | {#each statuses as status} 229 | { 232 | view.removeStatus(creature, status); 233 | }} 234 | /> 235 | {/each} 236 |
237 | 238 |
239 | 240 | 312 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MarkdownPostProcessorContext, 3 | Notice, 4 | parseYaml, 5 | Plugin, 6 | WorkspaceLeaf, 7 | } from "obsidian"; 8 | 9 | import { 10 | DEFAULT_SETTINGS, 11 | INTIATIVE_TRACKER_VIEW, 12 | registerIcons, 13 | } from "./utils"; 14 | import type { 15 | EventsOnArgs, 16 | HomebrewCreature, 17 | InitiativeTrackerData, 18 | } from "../@types/index"; 19 | 20 | import InitiativeTrackerSettings from "./settings"; 21 | 22 | import { Creature } from "./utils/creature"; 23 | 24 | import Encounter from "./svelte/Encounter.svelte"; 25 | 26 | import TrackerView from "./view"; 27 | declare module "obsidian" { 28 | interface App { 29 | plugins: { 30 | plugins: { 31 | "obsidian-dice-roller": { 32 | parseDice(text: string): Promise<{ result: number }>; 33 | }; 34 | "initiative-tracker": InitiativeTracker; 35 | }; 36 | }; 37 | } 38 | interface WorkspaceItem { 39 | containerEl: HTMLElement; 40 | } 41 | interface Workspace { 42 | on(...args: EventsOnArgs): EventRef; 43 | } 44 | } 45 | 46 | export default class InitiativeTracker extends Plugin { 47 | public data: InitiativeTrackerData; 48 | playerCreatures: Map = new Map(); 49 | homebrewCreatures: Map = new Map(); 50 | 51 | get canUseDiceRoller() { 52 | return "obsidian-dice-roller" in this.app.plugins.plugins; 53 | } 54 | 55 | get view() { 56 | const leaves = this.app.workspace.getLeavesOfType(INTIATIVE_TRACKER_VIEW); 57 | const leaf = leaves.length ? leaves[0] : null; 58 | if (leaf && leaf.view && leaf.view instanceof TrackerView) return leaf.view; 59 | } 60 | 61 | async onload() { 62 | registerIcons(); 63 | 64 | await this.loadSettings(); 65 | 66 | this.addSettingTab(new InitiativeTrackerSettings(this)); 67 | 68 | this.registerView( 69 | INTIATIVE_TRACKER_VIEW, 70 | (leaf: WorkspaceLeaf) => new TrackerView(leaf, this) 71 | ); 72 | 73 | this.addCommands(); 74 | 75 | this.registerEvent( 76 | this.app.workspace.on( 77 | "initiative-tracker:should-save", 78 | async () => await this.saveSettings() 79 | ) 80 | ); 81 | this.registerMarkdownCodeBlockProcessor( 82 | "encounter", 83 | this.encounterProcessor.bind(this) 84 | ); 85 | 86 | this.playerCreatures = new Map( 87 | this.data.players.map((p) => [p, new Creature(p)]) 88 | ); 89 | 90 | this.app.workspace.onLayoutReady(() => this.addTrackerView()); 91 | 92 | console.log("Initiative Tracker v" + this.manifest.version + " loaded"); 93 | } 94 | 95 | encounterProcessor( 96 | src: string, 97 | el: HTMLElement, 98 | _: MarkdownPostProcessorContext 99 | ) { 100 | const encounters = src.split("---") ?? []; 101 | const containerEl = el.createDiv("encounter-container"); 102 | const empty = containerEl.createSpan({ 103 | text: "No encounters created. Please check your syntax and try again.", 104 | }); 105 | 106 | for (let encounter of encounters) { 107 | try { 108 | const params = parseYaml(encounter); 109 | const rawMonsters = params.creatures ?? []; 110 | 111 | let creatures: Creature[]; 112 | if (rawMonsters && rawMonsters instanceof Array) { 113 | creatures = rawMonsters 114 | .map((m) => { 115 | try { 116 | let monster: string | string[] = m, 117 | number = 1; 118 | if (typeof m === "object" && !(m instanceof Array)) { 119 | number = Number(Object.keys(m).shift()); 120 | monster = Object.values(m).shift() as string[]; 121 | } else if (typeof m === "string") { 122 | try { 123 | let [mon, num] = m.split(/:\s?/).reverse(); 124 | if (num && !isNaN(Number(num))) { 125 | number = Number(num); 126 | } 127 | monster = parseYaml(mon); 128 | } catch (e) { 129 | console.error(e); 130 | return; 131 | } 132 | } 133 | if (!monster.length) return; 134 | if (typeof monster == "string") { 135 | monster = [monster.split(",")].flat(); 136 | } 137 | let creature = new Creature({ 138 | name: monster[0], 139 | hp: 140 | monster[1] && !isNaN(Number(monster[1])) 141 | ? Number(monster[1]) 142 | : null, 143 | ac: 144 | monster[2] && !isNaN(Number(monster[2])) 145 | ? Number(monster[2]) 146 | : null, 147 | modifier: monster[3], 148 | }); 149 | 150 | return [ 151 | ...[...Array(number).keys()].map( 152 | (_) => new Creature(creature) 153 | ), 154 | ]; 155 | } catch (e) { 156 | new Notice( 157 | "Initiative Tracker: could not parse line: \n\n" + m 158 | ); 159 | } 160 | }) 161 | .filter((c) => c) 162 | .flat(); 163 | } 164 | 165 | const encounterEl = containerEl.createDiv("encounter"); 166 | 167 | let players: boolean | string[] = true; 168 | if (params.players) { 169 | if (params.players === "none") { 170 | players = false; 171 | } else { 172 | players = params.players; 173 | } 174 | } 175 | 176 | const instance = new Encounter({ 177 | target: encounterEl, 178 | props: { 179 | ...(params.name ? { name: params.name } : {}), 180 | players, 181 | creatures, 182 | }, 183 | }); 184 | 185 | instance.$on("begin-encounter", async () => { 186 | if (!this.view) { 187 | await this.addTrackerView(); 188 | } 189 | const view = this.view; 190 | if (view) { 191 | view.newEncounter({ 192 | ...params, 193 | creatures: creatures, 194 | }); 195 | this.app.workspace.revealLeaf(view.leaf); 196 | } else { 197 | new Notice( 198 | "Could not find the Initiative Tracker. Try reloading the note!" 199 | ); 200 | } 201 | }); 202 | empty.detach(); 203 | } catch (e) { 204 | console.error(e); 205 | new Notice( 206 | "Initiative Tracker: here was an issue parsing: \n\n" + encounter 207 | ); 208 | } 209 | } 210 | } 211 | 212 | addCommands() { 213 | this.addCommand({ 214 | id: "open-tracker", 215 | name: "Open Initiative Tracker", 216 | checkCallback: (checking) => { 217 | if (!this.view) { 218 | if (!checking) { 219 | this.addTrackerView(); 220 | } 221 | return true; 222 | } 223 | }, 224 | }); 225 | 226 | this.addCommand({ 227 | id: "toggle-encounter", 228 | name: "Toggle Encounter", 229 | checkCallback: (checking) => { 230 | const view = this.view; 231 | if (view) { 232 | if (!checking) { 233 | view.toggleState(); 234 | } 235 | return true; 236 | } 237 | }, 238 | }); 239 | 240 | this.addCommand({ 241 | id: "next-combatant", 242 | name: "Next Combatant", 243 | checkCallback: (checking) => { 244 | const view = this.view; 245 | if (view && view.state) { 246 | if (!checking) { 247 | view.goToNext(); 248 | } 249 | return true; 250 | } 251 | }, 252 | }); 253 | 254 | this.addCommand({ 255 | id: "prev-combatant", 256 | name: "Previous Combatant", 257 | checkCallback: (checking) => { 258 | const view = this.view; 259 | if (view && view.state) { 260 | if (!checking) { 261 | this.view.goToPrevious(); 262 | } 263 | return true; 264 | } 265 | }, 266 | }); 267 | } 268 | 269 | async onunload() { 270 | await this.saveSettings(); 271 | this.app.workspace.trigger("initiative-tracker:unload"); 272 | this.app.workspace 273 | .getLeavesOfType(INTIATIVE_TRACKER_VIEW) 274 | .forEach((leaf) => leaf.detach()); 275 | console.log("Initiative Tracker unloaded"); 276 | } 277 | 278 | async addTrackerView() { 279 | if (this.app.workspace.getLeavesOfType(INTIATIVE_TRACKER_VIEW).length) { 280 | return; 281 | } 282 | await this.app.workspace.getRightLeaf(false).setViewState({ 283 | type: INTIATIVE_TRACKER_VIEW, 284 | }); 285 | } 286 | 287 | async updatePlayer(existing: HomebrewCreature, player: HomebrewCreature) { 288 | if (!this.playerCreatures.has(existing)) { 289 | await this.savePlayer(player); 290 | return; 291 | } 292 | 293 | const creature = this.playerCreatures.get(existing); 294 | creature.update(player); 295 | 296 | this.data.players.splice(this.data.players.indexOf(existing), 1, player); 297 | 298 | this.playerCreatures.set(player, creature); 299 | this.playerCreatures.delete(existing); 300 | 301 | const view = this.view; 302 | if (view) { 303 | view.updateState(); 304 | } 305 | 306 | await this.saveSettings(); 307 | } 308 | 309 | async savePlayer(player: HomebrewCreature) { 310 | this.data.players.push(player); 311 | this.playerCreatures.set(player, new Creature(player)); 312 | await this.saveSettings(); 313 | } 314 | async savePlayers(...players: HomebrewCreature[]) { 315 | for (let player of players) { 316 | this.data.players.push(player); 317 | this.playerCreatures.set(player, new Creature(player)); 318 | } 319 | await this.saveSettings(); 320 | } 321 | 322 | async deletePlayer(player: HomebrewCreature) { 323 | this.data.players = this.data.players.filter((p) => p != player); 324 | this.playerCreatures.delete(player); 325 | await this.saveSettings(); 326 | } 327 | 328 | async loadSettings() { 329 | const data = Object.assign( 330 | {}, 331 | { ...DEFAULT_SETTINGS }, 332 | await this.loadData() 333 | ); 334 | 335 | this.data = data; 336 | } 337 | 338 | async saveSettings() { 339 | await this.saveData(this.data); 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ButtonComponent, 3 | ExtraButtonComponent, 4 | Modal, 5 | Notice, 6 | PluginSettingTab, 7 | setIcon, 8 | Setting, 9 | } from "obsidian"; 10 | 11 | import type InitiativeTracker from "./main"; 12 | 13 | import { AC, DEFAULT_UNDEFINED, EDIT, HP, INITIATIVE } from "./utils"; 14 | import type { HomebrewCreature, InputValidate } from "@types"; 15 | 16 | export default class InitiativeTrackerSettings extends PluginSettingTab { 17 | constructor(private plugin: InitiativeTracker) { 18 | super(plugin.app, plugin); 19 | } 20 | async display(): Promise { 21 | try { 22 | let { containerEl } = this; 23 | 24 | containerEl.empty(); 25 | containerEl.addClass("initiative-tracker-settings"); 26 | 27 | containerEl.createEl("h2", { text: "Initiative Tracker Settings" }); 28 | const additionalContainer = containerEl.createDiv( 29 | "initiative-tracker-additional-container" 30 | ); 31 | 32 | this._displayPlayers(additionalContainer); 33 | 34 | if (this.plugin.canUseStatBlocks) { 35 | const syncEl = containerEl.createDiv("initiative-sync"); 36 | 37 | new Setting(syncEl) 38 | .setName("Sync Monsters from 5e Statblocks") 39 | .setDesc( 40 | "Homebrew creatures saved to the 5e Statblocks plugin will be available in the quick-add." 41 | ) 42 | .addToggle((t) => { 43 | t.setValue(this.plugin.data.sync); 44 | t.onChange(async (v) => { 45 | this.plugin.data.sync = v; 46 | 47 | await this.plugin.saveSettings(); 48 | this.display(); 49 | }); 50 | }); 51 | if (this.plugin.data.sync) { 52 | const synced = new Setting(syncEl).setDesc( 53 | `${this.plugin.statblock_creatures.length} creatures synced.` 54 | ); 55 | synced.settingEl.addClass("initiative-synced"); 56 | setIcon(synced.nameEl, "check-in-circle"); 57 | synced.nameEl.appendChild(createSpan({ text: "Synced" })); 58 | } 59 | } 60 | 61 | const formula = new Setting(containerEl) 62 | .setName("Initiative Formula") 63 | 64 | .addText((t) => { 65 | if (!this.plugin.canUseDiceRoller) { 66 | t.setDisabled(true); 67 | this.plugin.data.initiative = "1d20 + %mod%"; 68 | } 69 | t.setValue(this.plugin.data.initiative); 70 | t.onChange((v) => { 71 | this.plugin.data.initiative = v; 72 | }); 73 | t.inputEl.onblur = async () => { 74 | if (this.plugin.view) this.plugin.view.rollInitiatives(); 75 | await this.plugin.saveSettings(); 76 | }; 77 | }); 78 | 79 | formula.descEl.createSpan({ 80 | text: "Initiative formula to use when calculating initiative. Use ", 81 | }); 82 | formula.descEl.createEl("code", { text: "%mod%" }); 83 | formula.descEl.createSpan({ 84 | text: " for the modifier placeholder.", 85 | }); 86 | 87 | if (!this.plugin.canUseDiceRoller) { 88 | formula.descEl.createEl("br"); 89 | formula.descEl.createEl("br"); 90 | formula.descEl.createSpan({ 91 | attr: { 92 | style: `color: var(--text-error);`, 93 | }, 94 | text: "Requires the ", 95 | }); 96 | formula.descEl.createEl("a", { 97 | text: "Dice Roller", 98 | href: "https://github.com/valentine195/obsidian-dice-roller", 99 | cls: "external-link", 100 | }); 101 | formula.descEl.createSpan({ 102 | attr: { 103 | style: `color: var(--text-error);`, 104 | }, 105 | text: " plugin to modify.", 106 | }); 107 | } 108 | } catch (e) { 109 | console.error(e); 110 | new Notice( 111 | "There was an error displaying the settings tab for Obsidian Initiative Tracker." 112 | ); 113 | } 114 | } 115 | private _displayPlayers(additionalContainer: HTMLDivElement) { 116 | additionalContainer.empty(); 117 | const additional = additionalContainer.createDiv("additional"); 118 | new Setting(additional) 119 | .setName("Add New Player") 120 | .setDesc("These players will always be added to new encounters.") 121 | .addButton((button: ButtonComponent): ButtonComponent => { 122 | let b = button 123 | .setTooltip("Add Player") 124 | .setButtonText("+") 125 | .onClick(async () => { 126 | const modal = new NewPlayerModal(this.plugin); 127 | modal.open(); 128 | modal.onClose = async () => { 129 | if (!modal.saved) return; 130 | 131 | await this.plugin.savePlayer({ 132 | ...modal.player, 133 | player: true, 134 | }); 135 | 136 | this._displayPlayers(additionalContainer); 137 | }; 138 | }); 139 | 140 | return b; 141 | }); 142 | const playerView = additional.createDiv("initiative-tracker-players"); 143 | if (!this.plugin.data.players.length) { 144 | additional 145 | .createDiv({ 146 | attr: { 147 | style: 148 | "display: flex; justify-content: center; padding-bottom: 18px;", 149 | }, 150 | }) 151 | .createSpan({ 152 | text: "No saved players! Create one to see it here.", 153 | }); 154 | } else { 155 | const headers = playerView.createDiv("initiative-tracker-player headers"); 156 | 157 | headers.createDiv({ text: "Name" }); 158 | new ExtraButtonComponent(headers.createDiv()) 159 | .setIcon(HP) 160 | .setTooltip("Max HP"); 161 | new ExtraButtonComponent(headers.createDiv()) 162 | .setIcon(AC) 163 | .setTooltip("Armor Class"); 164 | new ExtraButtonComponent(headers.createDiv()) 165 | .setIcon(INITIATIVE) 166 | .setTooltip("Initiative Modifier"); 167 | 168 | headers.createDiv(); 169 | 170 | for (let player of this.plugin.data.players) { 171 | const playerDiv = playerView.createDiv("initiative-tracker-player"); 172 | playerDiv.createDiv({ text: player.name }); 173 | playerDiv.createDiv({ 174 | text: `${player.hp ?? DEFAULT_UNDEFINED}`, 175 | }); 176 | playerDiv.createDiv({ 177 | text: `${player.ac ?? DEFAULT_UNDEFINED}`, 178 | }); 179 | playerDiv.createDiv({ 180 | text: `${player.modifier ?? DEFAULT_UNDEFINED}`, 181 | }); 182 | const icons = playerDiv.createDiv("initiative-tracker-player-icon"); 183 | new ExtraButtonComponent(icons.createDiv()) 184 | .setIcon(EDIT) 185 | .setTooltip("Edit") 186 | .onClick(() => { 187 | const modal = new NewPlayerModal(this.plugin, player); 188 | modal.open(); 189 | modal.onClose = async () => { 190 | if (!modal.saved) return; 191 | await this.plugin.updatePlayer(player, modal.player); 192 | this.plugin.app.workspace.trigger( 193 | "initiative-tracker:creature-updated-in-settings", 194 | player 195 | ); 196 | 197 | this._displayPlayers(additionalContainer); 198 | }; 199 | }); 200 | new ExtraButtonComponent(icons.createDiv()) 201 | .setIcon("trash") 202 | .setTooltip("Delete") 203 | .onClick(async () => { 204 | this.plugin.data.players = this.plugin.data.players.filter( 205 | (p) => p != player 206 | ); 207 | 208 | await this.plugin.saveSettings(); 209 | this._displayPlayers(additionalContainer); 210 | }); 211 | } 212 | } 213 | } 214 | } 215 | 216 | class NewPlayerModal extends Modal { 217 | player: HomebrewCreature; 218 | saved: boolean; 219 | constructor( 220 | private plugin: InitiativeTracker, 221 | private original?: HomebrewCreature 222 | ) { 223 | super(plugin.app); 224 | this.player = { ...(original ?? {}) }; 225 | } 226 | async display(load?: boolean) { 227 | let { contentEl } = this; 228 | 229 | contentEl.addClass("initiative-tracker-add-player-modal"); 230 | 231 | contentEl.empty(); 232 | 233 | contentEl.createEl("h2", { 234 | text: this.original ? "Edit Player" : "New Player", 235 | }); 236 | 237 | let nameInput: InputValidate, 238 | hpInput: InputValidate, 239 | acInput: InputValidate, 240 | modInput: InputValidate; 241 | 242 | new Setting(contentEl) 243 | .setName("Name") 244 | .setDesc("Player name. Must be unique!") 245 | .addText((t) => { 246 | nameInput = { 247 | input: t.inputEl, 248 | validate: (i: HTMLInputElement) => { 249 | let error = false; 250 | if ( 251 | (!i.value.length && !load) || 252 | (this.plugin.data.players.find((p) => p.name === i.value) && 253 | this.player.name != this.original.name) 254 | ) { 255 | i.addClass("has-error"); 256 | error = true; 257 | } 258 | return error; 259 | }, 260 | }; 261 | t.setValue(this.player.name ?? ""); 262 | t.onChange((v) => { 263 | t.inputEl.removeClass("has-error"); 264 | this.player.name = v; 265 | }); 266 | }); 267 | new Setting(contentEl).setName("Max Hit Points").addText((t) => { 268 | hpInput = { 269 | input: t.inputEl, 270 | validate: (i: HTMLInputElement) => { 271 | let error = false; 272 | if (isNaN(Number(i.value))) { 273 | i.addClass("has-error"); 274 | error = true; 275 | } 276 | return error; 277 | }, 278 | }; 279 | t.setValue(`${this.player.hp ?? ""}`); 280 | t.onChange((v) => { 281 | t.inputEl.removeClass("has-error"); 282 | this.player.hp = Number(v); 283 | }); 284 | }); 285 | new Setting(contentEl).setName("Armor Class").addText((t) => { 286 | acInput = { 287 | input: t.inputEl, 288 | validate: (i) => { 289 | let error = false; 290 | if (isNaN(Number(i.value))) { 291 | t.inputEl.addClass("has-error"); 292 | error = true; 293 | } 294 | return error; 295 | }, 296 | }; 297 | t.setValue(`${this.player.ac ?? ""}`); 298 | t.onChange((v) => { 299 | t.inputEl.removeClass("has-error"); 300 | this.player.ac = Number(v); 301 | }); 302 | }); 303 | new Setting(contentEl) 304 | .setName("Initiative Modifier") 305 | .setDesc("This will be added to randomly-rolled initiatives.") 306 | .addText((t) => { 307 | modInput = { 308 | input: t.inputEl, 309 | validate: (_) => { 310 | return false; 311 | }, 312 | }; 313 | t.setValue(`${this.player.modifier ?? ""}`); 314 | t.onChange((v) => { 315 | this.player.modifier = v; 316 | }); 317 | }); 318 | 319 | let footerEl = contentEl.createDiv(); 320 | let footerButtons = new Setting(footerEl); 321 | footerButtons.addButton((b) => { 322 | b.setTooltip("Save") 323 | .setIcon("checkmark") 324 | .onClick(async () => { 325 | let error = this.validateInputs( 326 | nameInput, 327 | acInput, 328 | hpInput, 329 | modInput 330 | ); 331 | if (error) { 332 | new Notice("Fix errors before saving."); 333 | return; 334 | } 335 | this.saved = true; 336 | this.close(); 337 | }); 338 | return b; 339 | }); 340 | footerButtons.addExtraButton((b) => { 341 | b.setIcon("cross") 342 | .setTooltip("Cancel") 343 | .onClick(() => { 344 | this.saved = false; 345 | this.close(); 346 | }); 347 | return b; 348 | }); 349 | 350 | this.validateInputs(nameInput, acInput, hpInput, modInput); 351 | } 352 | validateInputs(...inputs: InputValidate[]) { 353 | let error = false; 354 | for (let input of inputs) { 355 | if (input.validate(input.input)) { 356 | error = true; 357 | } else { 358 | input.input.removeClass("has-error"); 359 | } 360 | } 361 | return error; 362 | } 363 | onOpen() { 364 | this.display(true); 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /src/utils/suggester.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | FuzzyMatch, 4 | FuzzySuggestModal, 5 | Scope, 6 | Setting, 7 | SuggestModal, 8 | } from "obsidian"; 9 | import { createPopper, Instance as PopperInstance } from "@popperjs/core"; 10 | 11 | import type { HomebrewCreature, Condition } from "@types"; 12 | import type InitiativeTracker from "src/main"; 13 | 14 | class Suggester { 15 | owner: SuggestModal; 16 | items: T[]; 17 | suggestions: HTMLDivElement[]; 18 | selectedItem: number; 19 | containerEl: HTMLElement; 20 | constructor(owner: SuggestModal, containerEl: HTMLElement, scope: Scope) { 21 | this.containerEl = containerEl; 22 | this.owner = owner; 23 | containerEl.on( 24 | "click", 25 | ".suggestion-item", 26 | this.onSuggestionClick.bind(this) 27 | ); 28 | containerEl.on( 29 | "mousemove", 30 | ".suggestion-item", 31 | this.onSuggestionMouseover.bind(this) 32 | ); 33 | 34 | scope.register([], "ArrowUp", () => { 35 | this.setSelectedItem(this.selectedItem - 1, true); 36 | return false; 37 | }); 38 | 39 | scope.register([], "ArrowDown", () => { 40 | this.setSelectedItem(this.selectedItem + 1, true); 41 | return false; 42 | }); 43 | 44 | scope.register([], "Enter", (evt) => { 45 | this.useSelectedItem(evt); 46 | return false; 47 | }); 48 | 49 | scope.register([], "Tab", (evt) => { 50 | this.useSelectedItem(evt); 51 | return false; 52 | }); 53 | } 54 | chooseSuggestion(evt: KeyboardEvent) { 55 | if (!this.items || !this.items.length) return; 56 | const currentValue = this.items[this.selectedItem]; 57 | if (currentValue) { 58 | this.owner.selectSuggestion(currentValue, evt); 59 | } 60 | } 61 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 62 | event.preventDefault(); 63 | if (!this.suggestions || !this.suggestions.length) return; 64 | 65 | const item = this.suggestions.indexOf(el); 66 | this.setSelectedItem(item, false); 67 | this.useSelectedItem(event); 68 | } 69 | 70 | onSuggestionMouseover(_: MouseEvent, el: HTMLDivElement): void { 71 | if (!this.suggestions || !this.suggestions.length) return; 72 | const item = this.suggestions.indexOf(el); 73 | this.setSelectedItem(item, false); 74 | } 75 | empty() { 76 | this.containerEl.empty(); 77 | } 78 | setSuggestions(items: T[]) { 79 | this.containerEl.empty(); 80 | const els: HTMLDivElement[] = []; 81 | 82 | items.forEach((item) => { 83 | const suggestionEl = this.containerEl.createDiv("suggestion-item"); 84 | this.owner.renderSuggestion(item, suggestionEl); 85 | els.push(suggestionEl); 86 | }); 87 | this.items = items; 88 | this.suggestions = els; 89 | this.setSelectedItem(0, false); 90 | } 91 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 92 | if (!this.items || !this.items.length) return; 93 | 94 | const currentValue = this.items[this.selectedItem]; 95 | 96 | if (currentValue) { 97 | this.owner.selectSuggestion(currentValue, event); 98 | } 99 | } 100 | wrap(value: number, size: number): number { 101 | return ((value % size) + size) % size; 102 | } 103 | setSelectedItem(index: number, scroll: boolean) { 104 | const nIndex = this.wrap(index, this.suggestions.length); 105 | const prev = this.suggestions[this.selectedItem]; 106 | const next = this.suggestions[nIndex]; 107 | 108 | if (prev) prev.removeClass("is-selected"); 109 | if (next) next.addClass("is-selected"); 110 | 111 | this.selectedItem = nIndex; 112 | 113 | if (scroll) { 114 | next.scrollIntoView(false); 115 | } 116 | } 117 | } 118 | 119 | abstract class SuggestionModal extends FuzzySuggestModal { 120 | items: T[] = []; 121 | suggestions: HTMLDivElement[]; 122 | popper: PopperInstance; 123 | scope: Scope = new Scope(); 124 | suggester: Suggester>; 125 | suggestEl: HTMLDivElement; 126 | promptEl: HTMLDivElement; 127 | emptyStateText: string = "No match found"; 128 | limit: number = 25; 129 | constructor(app: App, inputEl: HTMLInputElement) { 130 | super(app); 131 | this.inputEl = inputEl; 132 | 133 | this.suggestEl = createDiv({ 134 | attr: { style: "min-width: 475px;" }, 135 | cls: "suggestion-container", 136 | }); 137 | 138 | this.contentEl = this.suggestEl.createDiv("suggestion"); 139 | 140 | this.suggester = new Suggester(this, this.contentEl, this.scope); 141 | 142 | this.scope.register([], "Escape", this.close.bind(this)); 143 | 144 | this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); 145 | /* this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); */ 146 | this.inputEl.addEventListener("blur", this.close.bind(this)); 147 | this.suggestEl.on( 148 | "mousedown", 149 | ".suggestion-container", 150 | (event: MouseEvent) => { 151 | event.preventDefault(); 152 | } 153 | ); 154 | } 155 | empty() { 156 | this.suggester.empty(); 157 | } 158 | onInputChanged(): void { 159 | const inputStr = this.modifyInput(this.inputEl.value); 160 | const suggestions = this.getSuggestions(inputStr); 161 | 162 | if (suggestions.length > 0) { 163 | this.suggester.setSuggestions(suggestions.slice(0, this.limit)); 164 | } else { 165 | this.onNoSuggestion(); 166 | } 167 | this.open(); 168 | } 169 | 170 | modifyInput(input: string): string { 171 | return input; 172 | } 173 | onNoSuggestion() { 174 | this.empty(); 175 | this.renderSuggestion(null, this.contentEl.createDiv("suggestion-item")); 176 | } 177 | open(): void { 178 | // TODO: Figure out a better way to do this. Idea from Periodic Notes plugin 179 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 180 | (this.app).keymap.pushScope(this.scope); 181 | 182 | document.body.appendChild(this.suggestEl); 183 | this.popper = createPopper(this.inputEl, this.suggestEl, { 184 | placement: "auto-start", 185 | modifiers: [ 186 | { 187 | name: "offset", 188 | options: { 189 | offset: [0, 10], 190 | }, 191 | }, 192 | { 193 | name: "flip", 194 | options: { 195 | allowedAutoPlacements: ["top-start", "bottom-start"], 196 | }, 197 | }, 198 | ], 199 | }); 200 | } 201 | 202 | close(): void { 203 | // TODO: Figure out a better way to do this. Idea from Periodic Notes plugin 204 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 205 | (this.app).keymap.popScope(this.scope); 206 | 207 | this.suggester.setSuggestions([]); 208 | if (this.popper) { 209 | this.popper.destroy(); 210 | } 211 | 212 | this.suggestEl.detach(); 213 | } 214 | createPrompt(prompts: HTMLSpanElement[]) { 215 | if (!this.promptEl) 216 | this.promptEl = this.suggestEl.createDiv("prompt-instructions"); 217 | let prompt = this.promptEl.createDiv("prompt-instruction"); 218 | for (let p of prompts) { 219 | prompt.appendChild(p); 220 | } 221 | } 222 | abstract onChooseItem(item: T, evt: MouseEvent | KeyboardEvent): void; 223 | abstract getItemText(arg: T): string; 224 | abstract getItems(): T[]; 225 | } 226 | 227 | abstract class ElementSuggestionModal extends FuzzySuggestModal { 228 | items: T[] = []; 229 | suggestions: HTMLDivElement[]; 230 | scope: Scope = new Scope(); 231 | suggester: Suggester>; 232 | suggestEl: HTMLDivElement; 233 | promptEl: HTMLDivElement; 234 | emptyStateText: string = "No match found"; 235 | limit: number = Infinity; 236 | filteredItems: FuzzyMatch[] = []; 237 | constructor(app: App, inputEl: HTMLInputElement, suggestEl: HTMLDivElement) { 238 | super(app); 239 | this.inputEl = inputEl; 240 | 241 | this.suggestEl = suggestEl.createDiv(/* "suggestion-container" */); 242 | 243 | this.contentEl = this.suggestEl.createDiv(/* "suggestion" */); 244 | 245 | this.suggester = new Suggester(this, this.contentEl, this.scope); 246 | 247 | this.scope.register([], "Escape", this.close.bind(this)); 248 | 249 | this.inputEl.addEventListener("input", this._onInputChanged.bind(this)); 250 | this.inputEl.addEventListener("focus", this._onInputChanged.bind(this)); 251 | this.inputEl.addEventListener("blur", this.close.bind(this)); 252 | this.suggestEl.on( 253 | "mousedown", 254 | ".suggestion-container", 255 | (event: MouseEvent) => { 256 | event.preventDefault(); 257 | } 258 | ); 259 | } 260 | empty() { 261 | this.suggester.empty(); 262 | } 263 | _onInputChanged(): void { 264 | const inputStr = this.inputEl.value; 265 | this.filteredItems = this.getSuggestions(inputStr); 266 | if (this.filteredItems.length > 0) { 267 | this.suggester.setSuggestions(this.filteredItems.slice(0, this.limit)); 268 | } else { 269 | this.onNoSuggestion(); 270 | } 271 | this.onInputChanged(); 272 | this.open(); 273 | } 274 | onInputChanged(): void {} 275 | onNoSuggestion() { 276 | this.empty(); 277 | this.renderSuggestion( 278 | null, 279 | this.contentEl.createDiv(/* "suggestion-item" */) 280 | ); 281 | } 282 | open(): void {} 283 | 284 | close(): void {} 285 | createPrompt(prompts: HTMLSpanElement[]) { 286 | if (!this.promptEl) 287 | this.promptEl = this.suggestEl.createDiv("prompt-instructions"); 288 | let prompt = this.promptEl.createDiv("prompt-instruction"); 289 | for (let p of prompts) { 290 | prompt.appendChild(p); 291 | } 292 | } 293 | abstract onChooseItem(item: T, evt: MouseEvent | KeyboardEvent): void; 294 | abstract getItemText(arg: T): string; 295 | abstract getItems(): T[]; 296 | } 297 | 298 | export class HomebrewMonsterSuggestionModal extends ElementSuggestionModal { 299 | creature: HomebrewCreature; 300 | homebrew: HomebrewCreature[]; 301 | constructor( 302 | public plugin: InitiativeTracker, 303 | inputEl: HTMLInputElement, 304 | el: HTMLDivElement 305 | ) { 306 | super(plugin.app, inputEl, el); 307 | this.homebrew = [...this.plugin.data.homebrew]; 308 | this._onInputChanged(); 309 | } 310 | getItems() { 311 | return this.homebrew; 312 | } 313 | getItemText(item: HomebrewCreature) { 314 | return item.name; 315 | } 316 | 317 | onChooseItem(item: HomebrewCreature) { 318 | this.inputEl.value = item.name; 319 | this.creature = item; 320 | } 321 | selectSuggestion(_: FuzzyMatch) { 322 | return; 323 | } 324 | renderSuggestion(result: FuzzyMatch, el: HTMLElement) { 325 | let { item, match: matches } = result || {}; 326 | let content = new Setting(el); /* el.createDiv({ 327 | cls: "suggestion-content" 328 | }); */ 329 | if (!item) { 330 | content.nameEl.setText(this.emptyStateText); 331 | /* content.parentElement.addClass("is-selected"); */ 332 | return; 333 | } 334 | 335 | const matchElements = matches.matches.map((_) => { 336 | return createSpan("suggestion-highlight"); 337 | }); 338 | for (let i = 0; i < item.name.length; i++) { 339 | let match = matches.matches.find((m) => m[0] === i); 340 | if (match) { 341 | let element = matchElements[matches.matches.indexOf(match)]; 342 | content.nameEl.appendChild(element); 343 | element.appendText(item.name.substring(match[0], match[1])); 344 | 345 | i += match[1] - match[0] - 1; 346 | continue; 347 | } 348 | 349 | content.nameEl.appendText(item.name[i]); 350 | } 351 | 352 | content.setDesc(item.source ?? ""); 353 | content.addExtraButton((b) => { 354 | b.setIcon("pencil") 355 | .setTooltip("Edit") 356 | .onClick(() => this.onEditItem(item)); 357 | }); 358 | content.addExtraButton((b) => { 359 | b.setIcon("trash") 360 | .setTooltip("Delete") 361 | .onClick(() => this.onRemoveItem(item)); 362 | }); 363 | } 364 | onEditItem(_: HomebrewCreature) {} 365 | onRemoveItem(_: HomebrewCreature) {} 366 | } 367 | 368 | export class ConditionSuggestionModal extends SuggestionModal { 369 | items: Condition[] = []; 370 | condition: Condition; 371 | constructor(public plugin: InitiativeTracker, inputEl: HTMLInputElement) { 372 | super(plugin.app, inputEl); 373 | 374 | const cache = plugin.app.metadataCache; 375 | const fileNames = cache.getCachedFiles(); 376 | const conditions = fileNames.filter((fileName) => 377 | (cache.getCache(fileName).tags || []).some( 378 | (tag) => tag.tag == "#condition" 379 | ) 380 | ); 381 | this.items = conditions.map((condition) => { 382 | const link = cache.getFirstLinkpathDest(condition, condition); 383 | return { 384 | name: link.basename, 385 | description: link.unsafeCachedData, 386 | path: link.path, 387 | }; 388 | }); 389 | 390 | this.suggestEl.style.removeProperty("min-width"); 391 | this.onInputChanged(); 392 | } 393 | getItemText(item: Condition) { 394 | return item.name; 395 | } 396 | getItems() { 397 | return this.items; 398 | } 399 | onChooseItem(item: Condition) { 400 | this.inputEl.value = item.name; 401 | this.condition = item; 402 | } 403 | onNoSuggestion() { 404 | this.empty(); 405 | this.renderSuggestion(null, this.contentEl.createDiv("suggestion-item")); 406 | this.condition = null; 407 | } 408 | selectSuggestion({ item }: FuzzyMatch) { 409 | if (this.condition !== null) { 410 | this.inputEl.value = item.name; 411 | this.condition = item; 412 | } else { 413 | this.condition = { 414 | name: this.inputEl.value, 415 | description: [], 416 | }; 417 | } 418 | 419 | this.onClose(); 420 | this.close(); 421 | } 422 | renderSuggestion(result: FuzzyMatch, el: HTMLElement) { 423 | let { item, match: matches } = result || {}; 424 | let content = new Setting(el); 425 | if (!item) { 426 | content.nameEl.setText(this.emptyStateText); 427 | this.condition = null; 428 | return; 429 | } 430 | 431 | const matchElements = matches.matches.map((_) => { 432 | return createSpan("suggestion-highlight"); 433 | }); 434 | 435 | for (let i = 0; i < item.name.length; i++) { 436 | let match = matches.matches.find((m) => m[0] === i); 437 | if (match) { 438 | let element = matchElements[matches.matches.indexOf(match)]; 439 | content.nameEl.appendChild(element); 440 | element.appendText(item.name.substring(match[0], match[1])); 441 | 442 | i += match[1] - match[0] - 1; 443 | continue; 444 | } 445 | 446 | content.nameEl.appendText(item.name[i]); 447 | } 448 | } 449 | } 450 | -------------------------------------------------------------------------------- /src/view.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, Platform, WorkspaceLeaf } from "obsidian"; 2 | import { BASE, INTIATIVE_TRACKER_VIEW, MIN_WIDTH_FOR_HAMBURGER } from "./utils"; 3 | 4 | import type InitiativeTracker from "./main"; 5 | 6 | import App from "./svelte/App.svelte"; 7 | import { Creature } from "./utils/creature"; 8 | import type { 9 | Condition, 10 | InitiativeViewState, 11 | TrackerEvents, 12 | TrackerViewState, 13 | } from "@types"; 14 | 15 | import store from "./svelte/store"; 16 | 17 | export default class TrackerView extends ItemView { 18 | public creatures: Creature[] = []; 19 | public current: number = 0; 20 | 21 | public state: boolean = false; 22 | 23 | public name: string; 24 | 25 | private _app: App; 26 | private _rendered: boolean = false; 27 | 28 | get pcs() { 29 | return this.players; 30 | } 31 | get npcs() { 32 | return this.creatures.filter((c) => !c.player); 33 | } 34 | 35 | get players() { 36 | return Array.from(this.plugin.playerCreatures.values()); 37 | } 38 | 39 | updatePlayers() { 40 | this.trigger("initiative-tracker:players-updated", this.pcs); 41 | this.setAppState({ 42 | creatures: this.ordered, 43 | }); 44 | } 45 | 46 | updateState() { 47 | this.setAppState(this.appState); 48 | } 49 | 50 | constructor(public leaf: WorkspaceLeaf, public plugin: InitiativeTracker) { 51 | super(leaf); 52 | 53 | if (this.plugin.data.state?.creatures?.length) { 54 | this.newEncounterFromState(this.plugin.data.state); 55 | } else { 56 | this.newEncounter(); 57 | } 58 | 59 | this.registerEvent( 60 | this.app.workspace.on( 61 | "initiative-tracker:add-creature-here", 62 | async (latlng: L.LatLng) => { 63 | this.app.workspace.revealLeaf(this.leaf); 64 | let addNewAsync = this._app.$on("add-new-async", (evt) => { 65 | const creature = evt.detail; 66 | this._addCreature(creature); 67 | 68 | this.trigger( 69 | "initiative-tracker:creature-added-at-location", 70 | creature, 71 | latlng 72 | ); 73 | addNewAsync(); 74 | cancel(); 75 | }); 76 | let cancel = this._app.$on("cancel-add-new-async", () => { 77 | addNewAsync(); 78 | cancel(); 79 | }); 80 | this._app.$set({ addNewAsync: true }); 81 | } 82 | ) 83 | ); 84 | this.registerEvent( 85 | this.app.workspace.on( 86 | "initiative-tracker:creature-updated-in-settings", 87 | (creature: Creature) => { 88 | const existing = this.creatures.find((c) => c == creature); 89 | 90 | if (existing) { 91 | this.updateCreature(existing, creature); 92 | } 93 | } 94 | ) 95 | ); 96 | this.registerEvent( 97 | this.app.workspace.on( 98 | "initiative-tracker:remove", 99 | (creature: Creature) => { 100 | const existing = this.creatures.find((c) => c.id == creature.id); 101 | 102 | if (existing) { 103 | this.removeCreature(existing); 104 | } 105 | } 106 | ) 107 | ); 108 | this.registerEvent( 109 | this.app.workspace.on( 110 | "initiative-tracker:enable-disable", 111 | (creature: Creature, enable: boolean) => { 112 | const existing = this.creatures.find((c) => c.id == creature.id); 113 | 114 | if (existing) { 115 | this.setCreatureState(existing, enable); 116 | } 117 | } 118 | ) 119 | ); 120 | this.registerEvent( 121 | this.app.workspace.on( 122 | "initiative-tracker:apply-damage", 123 | (creature: Creature) => { 124 | const existing = this.creatures.find((c) => c.id == creature.id); 125 | 126 | if (existing) { 127 | this.setAppState({ 128 | updatingHP: existing, 129 | }); 130 | } 131 | } 132 | ) 133 | ); 134 | this.registerEvent( 135 | this.app.workspace.on( 136 | "initiative-tracker:add-status", 137 | (creature: Creature) => { 138 | const existing = this.creatures.find((c) => c.id == creature.id); 139 | 140 | if (existing) { 141 | this.setAppState({ 142 | updatingStatus: existing, 143 | }); 144 | } 145 | } 146 | ) 147 | ); 148 | } 149 | newEncounterFromState(initiativeState: InitiativeViewState) { 150 | if (!initiativeState || !initiativeState?.creatures.length) { 151 | this.newEncounter(); 152 | } 153 | const { creatures, state, name, current } = initiativeState; 154 | this.creatures = [...creatures.map((c) => Creature.fromJSON(c))]; 155 | 156 | if (name) { 157 | this.name = name; 158 | this.setAppState({ 159 | name: this.name, 160 | }); 161 | } 162 | this.state = state; 163 | this.current = current; 164 | this.trigger("initiative-tracker:new-encounter", this.appState); 165 | 166 | this.setAppState({ 167 | creatures: this.ordered, 168 | }); 169 | } 170 | private _addCreature(creature: Creature) { 171 | this.creatures.push(creature); 172 | 173 | this.setAppState({ 174 | creatures: this.ordered, 175 | }); 176 | } 177 | onResize() { 178 | if (!this.leaf.getRoot() || !this.leaf.getRoot().containerEl) return; 179 | if (Platform.isMobile) return; 180 | 181 | this.setAppState({ 182 | show: 183 | this.leaf.getRoot().containerEl.clientWidth < MIN_WIDTH_FOR_HAMBURGER, 184 | }); 185 | } 186 | get ordered() { 187 | this.creatures.sort((a, b) => b.initiative - a.initiative); 188 | 189 | return this.creatures; 190 | } 191 | 192 | get enabled() { 193 | return this.ordered 194 | .map((c, i) => c.enabled && i) 195 | .filter((i) => typeof i === "number"); 196 | } 197 | 198 | addCreatures(...creatures: Creature[]) { 199 | for (let creature of creatures) { 200 | this.creatures.push(creature); 201 | } 202 | 203 | this.trigger("initiative-tracker:creatures-added", creatures); 204 | 205 | this.setAppState({ 206 | creatures: this.ordered, 207 | }); 208 | } 209 | 210 | removeCreature(...creatures: Creature[]) { 211 | for (let creature of creatures) { 212 | this.creatures = this.creatures.filter((c) => c != creature); 213 | } 214 | 215 | this.trigger("initiative-tracker:creatures-removed", creatures); 216 | this.setAppState({ 217 | creatures: this.ordered, 218 | }); 219 | } 220 | 221 | async newEncounter({ 222 | name, 223 | players = true, 224 | creatures = [], 225 | roll = true, 226 | }: { 227 | name?: string; 228 | players?: boolean | string[]; 229 | creatures?: Creature[]; 230 | roll?: boolean; 231 | } = {}) { 232 | if (players instanceof Array && players.length) { 233 | this.creatures = [ 234 | ...this.players.filter((p) => players.includes(p.name)), 235 | ]; 236 | } else if (players === true) { 237 | this.creatures = [...this.players]; 238 | } else { 239 | this.creatures = []; 240 | } 241 | if (creatures) this.creatures = [...this.creatures, ...creatures]; 242 | 243 | if (name) { 244 | this.name = name; 245 | this.setAppState({ 246 | name: this.name, 247 | }); 248 | } 249 | 250 | for (let creature of this.creatures) { 251 | creature.enabled = true; 252 | } 253 | 254 | this.trigger("initiative-tracker:new-encounter", this.appState); 255 | 256 | if (roll) await this.rollInitiatives(); 257 | else { 258 | this.setAppState({ 259 | creatures: this.ordered, 260 | }); 261 | } 262 | } 263 | 264 | resetEncounter() { 265 | for (let creature of this.creatures) { 266 | creature.hp = creature.max; 267 | this.setCreatureState(creature, true); 268 | const statuses = Array.from(creature.status); 269 | statuses.forEach((status) => { 270 | this.removeStatus(creature, status); 271 | }); 272 | } 273 | 274 | this.current = this.enabled[0]; 275 | 276 | this.setAppState({ 277 | creatures: this.ordered, 278 | }); 279 | } 280 | setMapState(v: boolean) { 281 | this.setAppState({ 282 | map: v, 283 | }); 284 | } 285 | async getInitiativeValue(modifier: number = 0): Promise { 286 | let initiative = Math.floor(Math.random() * 19 + 1) + modifier; 287 | if (this.plugin.canUseDiceRoller) { 288 | const num = await this.plugin.app.plugins.plugins[ 289 | "obsidian-dice-roller" 290 | ].parseDice( 291 | this.plugin.data.initiative.replace(/%mod%/g, `(${modifier})`) 292 | ); 293 | 294 | initiative = num.result; 295 | } 296 | return initiative; 297 | } 298 | 299 | async rollInitiatives() { 300 | for (let creature of this.creatures) { 301 | creature.initiative = await this.getInitiativeValue(creature.modifier); 302 | } 303 | 304 | this.setAppState({ 305 | creatures: this.ordered, 306 | }); 307 | } 308 | get appState(): TrackerViewState { 309 | return { 310 | state: this.state, 311 | current: this.current, 312 | pcs: this.pcs, 313 | npcs: this.npcs, 314 | creatures: this.ordered, 315 | }; 316 | } 317 | goToNext() { 318 | const current = this.enabled.indexOf(this.current); 319 | 320 | const next = 321 | (((current + 1) % this.enabled.length) + this.enabled.length) % 322 | this.enabled.length; 323 | 324 | this.current = this.enabled[next]; 325 | 326 | this.trigger( 327 | "initiative-tracker:active-change", 328 | this.ordered[this.current] 329 | ); 330 | 331 | this.setAppState({ 332 | state: this.state, 333 | current: this.current, 334 | }); 335 | } 336 | goToPrevious() { 337 | const current = this.enabled.indexOf(this.current); 338 | const next = 339 | (((current - 1) % this.enabled.length) + this.enabled.length) % 340 | this.enabled.length; 341 | 342 | this.current = this.enabled[next]; 343 | 344 | this.trigger( 345 | "initiative-tracker:active-change", 346 | this.ordered[this.current] 347 | ); 348 | 349 | this.setAppState({ 350 | state: this.state, 351 | current: this.current, 352 | }); 353 | } 354 | toggleState() { 355 | this.state = !this.state; 356 | 357 | if (this.state) { 358 | this.current = this.enabled[0]; 359 | 360 | this.trigger( 361 | "initiative-tracker:active-change", 362 | this.ordered[this.current] 363 | ); 364 | } else { 365 | this.trigger("initiative-tracker:active-change", null); 366 | } 367 | 368 | this.setAppState({ 369 | state: this.state, 370 | current: this.current, 371 | }); 372 | } 373 | addStatus(creature: Creature, tag: Condition) { 374 | creature.status.add(tag); 375 | 376 | this.trigger("initiative-tracker:creature-updated", creature); 377 | 378 | this.setAppState({ 379 | creatures: this.ordered, 380 | }); 381 | } 382 | removeStatus(creature: Creature, tag: Condition) { 383 | creature.status.delete(tag); 384 | 385 | this.trigger("initiative-tracker:creature-updated", creature); 386 | 387 | this.setAppState({ 388 | creatures: this.ordered, 389 | }); 390 | } 391 | updateCreature( 392 | creature: Creature, 393 | { 394 | hp, 395 | ac, 396 | initiative, 397 | name, 398 | }: { 399 | hp?: number; 400 | ac?: number; 401 | initiative?: number; 402 | name?: string; 403 | } 404 | ) { 405 | if (initiative) { 406 | creature.initiative = Number(initiative); 407 | } 408 | if (name) { 409 | creature.name = name; 410 | } 411 | if (hp) { 412 | creature.hp += Number(hp); 413 | } 414 | if (ac) { 415 | creature.ac = ac; 416 | } 417 | 418 | this.trigger("initiative-tracker:creature-updated", creature); 419 | 420 | this.setAppState({ 421 | creatures: this.ordered, 422 | }); 423 | } 424 | async copyInitiativeOrder() { 425 | const contents = this.ordered 426 | .map((creature) => `${creature.initiative} ${creature.name}`) 427 | .join("\n"); 428 | await navigator.clipboard.writeText(contents); 429 | } 430 | setCreatureState(creature: Creature, enabled: boolean) { 431 | if (enabled) { 432 | this._enableCreature(creature); 433 | } else { 434 | this._disableCreature(creature); 435 | } 436 | if (!this.enabled.length) { 437 | this.current = null; 438 | } 439 | 440 | this.trigger("initiative-tracker:creature-updated", creature); 441 | 442 | this.setAppState({ 443 | creatures: this.ordered, 444 | current: this.current, 445 | }); 446 | } 447 | private _enableCreature(creature: Creature) { 448 | creature.enabled = true; 449 | 450 | if (this.enabled.length == 1) { 451 | this.current = this.enabled[0]; 452 | } 453 | } 454 | private _disableCreature(creature: Creature) { 455 | if (this.ordered[this.current] == creature) { 456 | this.goToNext(); 457 | } 458 | creature.enabled = false; 459 | } 460 | 461 | setAppState(state: { [key: string]: any }) { 462 | if (this._app && this._rendered) { 463 | this.plugin.app.workspace.trigger( 464 | "initiative-tracker:state-change", 465 | this.appState 466 | ); 467 | this._app.$set(state); 468 | } 469 | 470 | this.plugin.data.state = this.toState(); 471 | this.trigger("initiative-tracker:should-save"); 472 | } 473 | async onOpen() { 474 | let show = Platform.isMobile 475 | ? true 476 | : this.leaf.getRoot?.().containerEl?.clientWidth < 477 | MIN_WIDTH_FOR_HAMBURGER ?? true; 478 | 479 | store.view.set(this); 480 | 481 | this._app = new App({ 482 | target: this.contentEl, 483 | props: { 484 | creatures: this.ordered, 485 | show: show, 486 | state: this.state, 487 | current: this.current, 488 | }, 489 | }); 490 | this._rendered = true; 491 | } 492 | 493 | async onClose() { 494 | this._app.$destroy(); 495 | this._rendered = false; 496 | this.trigger("initiative-tracker:closed"); 497 | } 498 | getViewType() { 499 | return INTIATIVE_TRACKER_VIEW; 500 | } 501 | getDisplayText() { 502 | return "Initiative Tracker"; 503 | } 504 | getIcon() { 505 | return BASE; 506 | } 507 | 508 | trigger(...args: TrackerEvents) { 509 | const [name, ...data] = args; 510 | this.app.workspace.trigger(name, ...data); 511 | } 512 | toState() { 513 | if (!this.state) return null; 514 | return { 515 | creatures: [...this.ordered.map((c) => c.toJSON())], 516 | state: this.state, 517 | current: this.current, 518 | name: this.name, 519 | }; 520 | } 521 | async onunload() { 522 | this.plugin.data.state = this.toState(); 523 | await this.plugin.saveSettings(); 524 | } 525 | } 526 | -------------------------------------------------------------------------------- /src/utils/icons.ts: -------------------------------------------------------------------------------- 1 | import { addIcon } from "obsidian"; 2 | 3 | export function registerIcons() { 4 | addIcon(BASE, ICON); 5 | 6 | addIcon(SAVE, SAVE_ICON); 7 | addIcon(ADD, ADD_ICON); 8 | addIcon(RESTART, RESTART_ICON); 9 | addIcon(PLAY, PLAY_ICON); 10 | addIcon(FORWARD, FORWARD_ICON); 11 | addIcon(BACKWARD, BACKWARD_ICON); 12 | addIcon(STOP, STOP_ICON); 13 | addIcon(GRIP, GRIP_ICON); 14 | addIcon(HP, HP_ICON); 15 | addIcon(AC, AC_ICON); 16 | addIcon(HAMBURGER, HAMBURGER_ICON); 17 | addIcon(ENABLE, ENABLE_ICON); 18 | addIcon(DISABLE, DISABLE_ICON); 19 | addIcon(TAG, TAG_ICON); 20 | addIcon(EDIT, EDIT_ICON); 21 | addIcon(INITIATIVE, INITIATIVE_ICON); 22 | addIcon(REDO, REDO_ICON); 23 | addIcon(NEW, NEW_ICON); 24 | addIcon(DICE, DICE_ICON); 25 | addIcon(START_ENCOUNTER, START_ENCOUNTER_ICON); 26 | addIcon(COPY, COPY_ICON); 27 | } 28 | 29 | export const BASE = "initiative-tracker"; 30 | const ICON = ``; 31 | 32 | export const START_ENCOUNTER = "crossed-swords"; 33 | const START_ENCOUNTER_ICON = 34 | ''; 35 | export const SAVE = "initiative-tracker-save"; 36 | const SAVE_ICON = ``; 37 | 38 | export const ADD = "initiative-tracker-add"; 39 | const ADD_ICON = ``; 40 | 41 | /* export const REMOVE = "initiative-tracker-remove"; */ 42 | export const REMOVE = "trash"; 43 | const REMOVE_ICON = ``; 44 | 45 | export const RESTART = "initiative-tracker-restart"; 46 | const RESTART_ICON = ``; 47 | 48 | export const PLAY = "initiative-tracker-play"; 49 | const PLAY_ICON = ``; 50 | 51 | export const FORWARD = "initiative-tracker-forward"; 52 | const FORWARD_ICON = ``; 53 | 54 | export const BACKWARD = "initiative-tracker-backward"; 55 | const BACKWARD_ICON = ``; 56 | 57 | export const STOP = "initiative-tracker-stop"; 58 | const STOP_ICON = ``; 59 | 60 | export const GRIP = "initiative-tracker-grip"; 61 | const GRIP_ICON = ``; 62 | 63 | export const HP = "initiative-tracker-hp"; 64 | const HP_ICON = ``; 65 | 66 | export const AC = "initiative-tracker-ac"; 67 | const AC_ICON = ``; 68 | 69 | export const HAMBURGER = "initiative-tracker-hamburger"; 70 | const HAMBURGER_ICON = ``; 71 | 72 | export const DISABLE = "initiative-tracker-disable"; 73 | const DISABLE_ICON = ``; 74 | 75 | export const ENABLE = "initiative-tracker-enable"; 76 | const ENABLE_ICON = ``; 77 | 78 | export const EDIT = "initiative-tracker-edit"; 79 | const EDIT_ICON = ``; 80 | 81 | export const TAG = "initiative-tracker-tags"; 82 | const TAG_ICON = ``; 83 | 84 | export const INITIATIVE = "initiative-tracker-initiative"; 85 | const INITIATIVE_ICON = ``; 86 | 87 | export const REDO = "initiative-tracker-redo"; 88 | const REDO_ICON = ``; 89 | 90 | export const NEW = "initiative-tracker-new"; 91 | const NEW_ICON = ``; 92 | 93 | export const DICE = "initiative-tracker-dice"; 94 | const DICE_ICON = ``; 95 | 96 | export const COPY = "initiative-tracker-copy"; 97 | const COPY_ICON = ``; 98 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . --------------------------------------------------------------------------------