├── requirements.txt ├── parsers ├── parser.py ├── __init__.py └── fantasygrounds.py ├── assets ├── img │ └── bg.png ├── js │ └── custom.js ├── css │ ├── custom.css │ └── global.css └── font │ ├── AndadaSC-Bold.otf │ ├── AndadaSC-Italic.otf │ ├── AndadaSC-Regular.otf │ ├── AndadaSC-BoldItalic.otf │ └── Solbera-Imitation.otf ├── models ├── group.py ├── __init__.py ├── page.py ├── encounter.py ├── combatant.py ├── marker.py ├── map.py └── module.py ├── README.md ├── packpacker.sh ├── convert.py ├── .gitignore └── ddbtoxml.py /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | natsort 3 | python-slugify 4 | -------------------------------------------------------------------------------- /parsers/parser.py: -------------------------------------------------------------------------------- 1 | class Parser: 2 | 3 | def process(path, module): 4 | pass -------------------------------------------------------------------------------- /parsers/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import Parser 2 | from .fantasygrounds import FantasyGrounds -------------------------------------------------------------------------------- /assets/img/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encounterplus/support-tools/HEAD/assets/img/bg.png -------------------------------------------------------------------------------- /assets/js/custom.js: -------------------------------------------------------------------------------- 1 | /* 2 | Encounter+ iOS 3 | version 1.0 4 | ---------------- 5 | custom js 6 | */ 7 | -------------------------------------------------------------------------------- /assets/css/custom.css: -------------------------------------------------------------------------------- 1 | /* 2 | Encounter+ iOS 3 | version 1.0 4 | ---------------- 5 | custom styles & theme 6 | */ 7 | -------------------------------------------------------------------------------- /assets/font/AndadaSC-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encounterplus/support-tools/HEAD/assets/font/AndadaSC-Bold.otf -------------------------------------------------------------------------------- /assets/font/AndadaSC-Italic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encounterplus/support-tools/HEAD/assets/font/AndadaSC-Italic.otf -------------------------------------------------------------------------------- /assets/font/AndadaSC-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encounterplus/support-tools/HEAD/assets/font/AndadaSC-Regular.otf -------------------------------------------------------------------------------- /assets/font/AndadaSC-BoldItalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encounterplus/support-tools/HEAD/assets/font/AndadaSC-BoldItalic.otf -------------------------------------------------------------------------------- /assets/font/Solbera-Imitation.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/encounterplus/support-tools/HEAD/assets/font/Solbera-Imitation.otf -------------------------------------------------------------------------------- /models/group.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | class Group: 4 | def __init__(self): 5 | self.id = str(uuid.uuid4()) 6 | self.parent = None 7 | self.name = None 8 | self.slug = None -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- 1 | from .combatant import Combatant 2 | from .encounter import Encounter 3 | from .group import Group 4 | from .map import Map 5 | from .marker import Marker 6 | from .page import Page 7 | from .module import Module -------------------------------------------------------------------------------- /models/page.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | class Page: 4 | def __init__(self): 5 | self.id = str(uuid.uuid4()) 6 | self.parent = None 7 | self.name = None 8 | self.slug = None 9 | self.content = "" 10 | self.meta = {} 11 | -------------------------------------------------------------------------------- /models/encounter.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | class Encounter: 4 | def __init__(self): 5 | self.id = str(uuid.uuid4()) 6 | self.parent = None 7 | self.name = None 8 | self.slug = None 9 | self.combatants = [] 10 | self.meta = {} 11 | -------------------------------------------------------------------------------- /models/combatant.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | class Combatant: 4 | def __init__(self): 5 | self.name = None 6 | self.label = None 7 | self.role = None 8 | self.x = None 9 | self.y = None 10 | self.monsterRef = None 11 | self.meta = {} 12 | -------------------------------------------------------------------------------- /models/marker.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | class Marker: 4 | def __init__(self): 5 | self.name = None 6 | self.label = None 7 | self.color = "#FF0000" 8 | self.shape = "pin" 9 | self.size = None 10 | self.hidden = None 11 | self.locked = None 12 | self.x = None 13 | self.y = None 14 | self.contentRef = None 15 | self.meta = {} 16 | -------------------------------------------------------------------------------- /models/map.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | class Map: 4 | def __init__(self): 5 | self.id = str(uuid.uuid4()) 6 | self.parent = None 7 | self.name = None 8 | self.slug = None 9 | self.gridColor = None 10 | self.gridSize = None 11 | self.gridOffsetX = None 12 | self.gridOffsetY = None 13 | self.image = None 14 | self.video = None 15 | self.scale = None 16 | self.markers = [] 17 | self.meta = {} 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # support-tools 2 | 3 | Few scripts for creating/converting modules for Encounter+ iOS app 4 | 5 | ## Usage 6 | 7 | ``` 8 | usage: convert.py [-h] [--parser PARSER] [--debug] [--name NAME] 9 | [--author AUTHOR] [--cover COVER] [--code CODE] [--id ID] 10 | PATH 11 | 12 | Convert existing modules to Encounter+ compatible file 13 | 14 | positional arguments: 15 | PATH a path to .mod, .xml, .db3 file to convert 16 | 17 | optional arguments: 18 | -h, --help show this help message and exit 19 | --parser PARSER data parser (fg|beyond) 20 | --debug enable debug logs 21 | --name NAME name 22 | --author AUTHOR author 23 | --cover COVER cover image 24 | --code CODE short code 25 | --id ID id 26 | ``` 27 | 28 | ## Example 29 | 30 | ``` 31 | ./convert.py ~/test.mod --name "Test Module" 32 | ``` 33 | 34 | ## Python3 install 35 | 36 | ``` 37 | pip3 install -r requirements.txt 38 | ``` 39 | 40 | ## Known issues 41 | 42 | `unpack_archive` is not working properly with python2.7, but it should be working with python3 43 | 44 | -------------------------------------------------------------------------------- /packpacker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | CWD=`pwd` 4 | PACK=`basename "$1"` 5 | SLUG=`basename "$1" | sed -e "s/ /-/" | tr '[:upper:]' '[:lower:]'` 6 | UUID=`uuidgen` 7 | echo -e "Generating ${PACK}.pack" 8 | 9 | cd $1 10 | 11 | echo -e '' > pack.xml 12 | echo -e "" >> pack.xml 13 | echo -e "\t${PACK}" >> pack.xml 14 | echo -e "\t${SLUG}" >> pack.xml 15 | echo -e "\t" >> pack.xml 16 | echo -e "\t" >> pack.xml 17 | echo -e "\t" >> pack.xml 18 | echo -e "\tpersonal" >> pack.xml 19 | echo -e "\t" >> pack.xml 20 | for file in ./*.* 21 | do 22 | BNAME=`basename $file` 23 | RNAME="${BNAME%.*}" 24 | if [ "${filename##*.}" != ".xml" ] 25 | then 26 | UUID=`uuidgen` 27 | echo -e "\t" >> pack.xml 28 | echo -e "\t\t${RNAME}" >> pack.xml 29 | echo -e "\t\t${BNAME}" >> pack.xml 30 | echo -e "\t\timage" >> pack.xml 31 | echo -e "\t\t" >> pack.xml 32 | echo -e "\t" >> pack.xml 33 | fi 34 | done 35 | echo -e "" >> pack.xml 36 | zip "../${PACK}.zip" *.* 37 | cd .. 38 | mv "${PACK}.zip" "${PACK}.pack" 39 | -------------------------------------------------------------------------------- /convert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import argparse 5 | import time 6 | import re 7 | import uuid 8 | import sys 9 | import shutil 10 | import logging 11 | 12 | # parse arguments 13 | parser = argparse.ArgumentParser(description="Convert existing modules to Encounter+ compatible file") 14 | parser.add_argument("path", metavar="PATH", help="a path to .mod, .xml, .db3 file to convert") 15 | parser.add_argument("--parser", default="fg", help="data parser (fg)") 16 | parser.add_argument("--debug", action="store_true", default=False, help="enable debug logs") 17 | parser.add_argument("--name", help="name") 18 | parser.add_argument("--author", help="author") 19 | parser.add_argument("--cover", help="cover image") 20 | parser.add_argument("--code", help="short code") 21 | parser.add_argument("--id", help="id") 22 | args = parser.parse_args() 23 | 24 | # setup logging 25 | if args.debug: 26 | logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) 27 | else: 28 | logging.basicConfig(stream=sys.stdout, level=logging.INFO) 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | from slugify import slugify 33 | from models import Module 34 | from parsers import FantasyGrounds 35 | 36 | if __name__ == "__main__": 37 | # create module 38 | module = Module() 39 | module.id = args.id or str(uuid.uuid4()) 40 | module.name = args.name or "Unknown" 41 | module.slug = slugify(module.name) 42 | module.author = args.author or "Unknown" 43 | module.code = args.code 44 | module.image = args.cover or "Cover.jpg" 45 | 46 | # create data parser 47 | dp = None 48 | 49 | if args.parser == "fg": 50 | # FantasyGrounds 51 | dp = FantasyGrounds() 52 | module.description = "Converted from FG" 53 | 54 | # process data in path 55 | dp.process(args.path, module) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | .DS_Store 127 | -------------------------------------------------------------------------------- /assets/css/global.css: -------------------------------------------------------------------------------- 1 | /* 2 | Encounter+ iOS 3 | version 1.0 4 | ---------------- 5 | global styles & theme 6 | */ 7 | 8 | @font-face { 9 | font-family: 'Andanda SC'; 10 | src: url('../font/AndadaSC-Regular.otf') format('opentype'); 11 | font-weight: normal; 12 | font-style: normal; 13 | } 14 | 15 | @font-face { 16 | font-family: 'Andanda SC'; 17 | src: url('../font/AndadaSC-BoldItalic.otf') format('opentype'); 18 | font-weight: bold; 19 | font-style: italic; 20 | } 21 | 22 | @font-face { 23 | font-family: 'Andanda SC'; 24 | src: url('../font/AndadaSC-Italic.otf') format('opentype'); 25 | font-weight: normal; 26 | font-style: italic; 27 | } 28 | 29 | @font-face { 30 | font-family: 'Andanda SC'; 31 | src: url('../font/AndadaSC-Bold.otf') format('opentype'); 32 | font-weight: bold; 33 | font-style: normal; 34 | } 35 | 36 | @font-face { 37 | font-family: 'Solbera Imitation'; 38 | src: url('../font/Solbera-Imitation.otf') format('opentype'); 39 | font-style: normal; 40 | } 41 | 42 | body { 43 | font-family: '-apple-system', sans-serif; 44 | font-size: 13px; 45 | line-height: 18px; 46 | background: #6d6d6d; 47 | padding: 0px; 48 | margin: 0px; 49 | color: black; 50 | background: url('../img/bg.png') #6d6d6d repeat; 51 | } 52 | 53 | #page { 54 | padding: 15px 20px; 55 | overflow: hidden; 56 | } 57 | 58 | h1, h2, h3, h4, h5, h6 { 59 | font-family: 'Andanda SC', '-apple-system', sans-serif;; 60 | color: #58180d; 61 | font-weight: normal; 62 | /* font-variant: small-caps;*/ 63 | display: block; 64 | padding: 0px; 65 | margin: 5px 0px; 66 | } 67 | 68 | h1 { 69 | font-size: 30px; 70 | line-height: 34px 71 | } 72 | 73 | h2 { 74 | font-size: 23px; 75 | line-height: 28px 76 | } 77 | 78 | h3 { 79 | font-size: 20px; 80 | border-bottom: 1px solid #58180d; 81 | line-height: 28px; 82 | } 83 | 84 | h4 { 85 | font-size: 18px; 86 | ]line-height: 23px; 87 | } 88 | 89 | h1 + p:not(.no-fancy)::first-letter { 90 | font-family: 'Solbera Imitation' !important; 91 | font-size: 93px; 92 | text-decoration: none; 93 | font-style: normal; 94 | font-weight: normal; 95 | line-height: 1; 96 | margin-top: -3px; 97 | padding-right: 8px; 98 | float: left; 99 | } 100 | 101 | blockquote { 102 | font-family: '-apple-system', sans-serif, !important; 103 | font-size: 13px; 104 | display: block; 105 | background-color: #e0e4c3; 106 | padding: 10px 15px; 107 | margin: 20px 0px; 108 | position: relative; 109 | } 110 | 111 | blockquote p:first-child { 112 | margin-top: 0px; 113 | } 114 | 115 | blockquote p:last-child { 116 | margin-bottom: 0px; 117 | } 118 | 119 | blockquote:before, 120 | blockquote:after { 121 | content: ""; 122 | position: absolute; 123 | background-repeat: no-repeat; 124 | } 125 | 126 | /* main block quote */ 127 | 128 | blockquote { 129 | border-top: 2px solid black; 130 | border-bottom: 2px solid black; 131 | box-shadow: 0px 3px 10px 0px rgba(0,0,0,0.3); 132 | } 133 | 134 | blockquote:before { 135 | width: 14px; 136 | background-image: linear-gradient(to bottom right, #fff0 0%, #fff0 50%, #000 50%, #000 100%), linear-gradient(to top right, #fff0 0%, #fff0 50%, #000 50%, #000 100%); 137 | background-size: 14px 7px; 138 | background-position: top center, bottom center; 139 | top: -8px; 140 | bottom: -8px; 141 | left: 0px; 142 | } 143 | 144 | blockquote:after { 145 | width: 14px; 146 | background-image: linear-gradient(to bottom left, #fff0 0%, #fff0 50%, #000 50%, #000 100%), linear-gradient(to top left, #fff0 0%, #fff0 50%, #000 50%, #000 100%); 147 | background-size: 14px 7px; 148 | background-position: top center, bottom center; 149 | top: -8px; 150 | bottom: -8px; 151 | right: 0px; 152 | } 153 | 154 | /* read aloud */ 155 | 156 | blockquote.read { 157 | border-top: none; 158 | border-bottom: none; 159 | border-left: 2px solid #58180d; 160 | border-right: 2px solid #58180d; 161 | background-color: #edebe8; 162 | box-shadow: none; 163 | } 164 | 165 | blockquote.read:before { 166 | width: 4px; 167 | background-image: radial-gradient(circle at center, #58180d 2px, transparent 0px), radial-gradient(circle at center, #58180d 2px, transparent 0px); 168 | background-size: 4px 4px; 169 | background-position: top center, bottom center; 170 | top: -3px; 171 | bottom: -3px; 172 | left: -3px; 173 | } 174 | 175 | blockquote.read:after { 176 | width: 4px; 177 | background-image: radial-gradient(circle at center, #58180d 2px, transparent 0px), radial-gradient(circle at center, #58180d 2px, transparent 0px); 178 | background-size: 4px 4px; 179 | background-position: top center, bottom center; 180 | top: -3px; 181 | bottom: -3px; 182 | right: -3px; 183 | } 184 | 185 | code, pre { 186 | white-space: pre; 187 | word-spacing: normal; 188 | word-break: normal; 189 | word-wrap: normal; 190 | -moz-tab-size: 4; 191 | -o-tab-size: 4; 192 | tab-size: 4; 193 | -webkit-hyphens: none; 194 | -moz-hyphens: none; 195 | -ms-hyphens: none; 196 | hyphens: none; 197 | } 198 | 199 | pre { 200 | background: #f5f5f5; 201 | padding: 10px 15px; 202 | margin: 10px 0px; 203 | border-top: 2px solid black; 204 | border-bottom: 2px solid black; 205 | } 206 | 207 | a { 208 | color: #58180d; 209 | text-decoration: none; 210 | /*font-weight: bold;*/ 211 | } 212 | 213 | a:hover { 214 | text-decoration: none; 215 | } 216 | 217 | a[href*="/monster/"], 218 | a[href*="/player/"], 219 | a[href*="/item/"], 220 | a[href*="/spell/"] { 221 | font-weight: bold; 222 | } 223 | 224 | .table-responsive, 225 | .table-overflow-wrapper { 226 | display: block; 227 | width: 100%; 228 | overflow-x: auto; 229 | -webkit-overflow-scrolling: touch; 230 | -ms-overflow-style: -ms-autohiding-scrollbar; 231 | } 232 | 233 | table { 234 | width: 100%; 235 | font-family: '-apple-system', sans-serif; 236 | border-collapse: collapse; 237 | margin-bottom: 30px; 238 | overflow-wrap: break-word; 239 | } 240 | 241 | table thead { 242 | font-weight: bold; 243 | } 244 | 245 | td, 246 | th { 247 | padding: 10px; 248 | } 249 | 250 | th p, 251 | td p { 252 | margin: 0; 253 | padding: 0; 254 | } 255 | 256 | tbody tr:nth-child(odd) { 257 | background: #e0e4c3; 258 | } 259 | 260 | .text-center { 261 | text-align: center; 262 | } 263 | 264 | .text-right { 265 | text-align: right; 266 | } 267 | 268 | .text-left { 269 | text-align: left; 270 | } 271 | 272 | img { 273 | max-width: 100%; 274 | height: auto; 275 | } 276 | 277 | img.size-cover, 278 | img.img-cover { 279 | max-width: none; 280 | width: calc(100% + 40px); 281 | margin-top: calc(-30px); 282 | margin-left: calc(-20px); 283 | margin-right: calc(-20px); 284 | height: auto; 285 | } 286 | 287 | img.size-full { 288 | width: 100%; 289 | height: auto; 290 | } 291 | 292 | @media print { 293 | body{ 294 | background: white; 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /models/module.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import logging 3 | import shutil 4 | import os 5 | from xml.etree import ElementTree 6 | 7 | from models import Group, Page, Map, Marker, Encounter, Combatant 8 | from slugify import slugify 9 | 10 | logging.basicConfig(level=logging.INFO) 11 | logger = logging.getLogger(__name__) 12 | 13 | class Module: 14 | 15 | def __init__(self): 16 | 17 | self.id = str(uuid.uuid4()) 18 | self.name = "Unknown module" 19 | self.slug = "unknown-module" 20 | self.description = None 21 | self.author = None 22 | self.code = None 23 | self.category = None 24 | self.image = None 25 | 26 | self.groups = [] 27 | self.pages = [] 28 | self.maps = [] 29 | self.encounters = [] 30 | 31 | def export_xml(self, path): 32 | 33 | # create module 34 | module = ElementTree.Element("module") 35 | 36 | module.set("id", self.id) 37 | ElementTree.SubElement(module, "name").text = self.name 38 | ElementTree.SubElement(module, "slug").text = self.slug 39 | 40 | if self.description: 41 | ElementTree.SubElement(module, "description").text = self.description 42 | 43 | if self.author: 44 | ElementTree.SubElement(module, "author").text = self.author 45 | 46 | if self.code: 47 | ElementTree.SubElement(module, "code").text = self.code 48 | 49 | if self.category: 50 | ElementTree.SubElement(module, "category").text = self.category 51 | 52 | if self.image: 53 | ElementTree.SubElement(module, "image").text = self.image 54 | 55 | # groups 56 | for group in self.groups: 57 | el = ElementTree.SubElement(module, "group") 58 | el.set("id", group.id) 59 | 60 | if group.parent != None: 61 | el.set("parent", group.parent.id) 62 | 63 | ElementTree.SubElement(el, "name").text = group.name 64 | ElementTree.SubElement(el, "slug").text = group.slug 65 | 66 | # pages 67 | for page in self.pages: 68 | el = ElementTree.SubElement(module, "page") 69 | el.set("id", page.id) 70 | 71 | if page.parent != None: 72 | el.set("parent", page.parent.id) 73 | 74 | ElementTree.SubElement(el, "name").text = page.name 75 | ElementTree.SubElement(el, "slug").text = page.slug 76 | ElementTree.SubElement(el, "content").text = page.content 77 | 78 | # maps 79 | for map in self.maps: 80 | 81 | el = ElementTree.SubElement(module, "map") 82 | el.set("id", map.id) 83 | 84 | if map.parent != None: 85 | el.set("parent", map.parent.id) 86 | 87 | ElementTree.SubElement(el, "name").text = map.name 88 | ElementTree.SubElement(el, "slug").text = map.slug 89 | 90 | if map.image: 91 | ElementTree.SubElement(el, "image").text = map.image 92 | 93 | if map.gridSize: 94 | ElementTree.SubElement(el, "gridSize").text = map.gridSize 95 | 96 | if map.gridOffsetX: 97 | ElementTree.SubElement(el, "gridOffsetX").text = map.gridOffsetX 98 | 99 | if map.gridOffsetY: 100 | ElementTree.SubElement(el, "gridOffsetY").text = map.gridOffsetY 101 | 102 | # markers 103 | for marker in map.markers: 104 | markerElement = ElementTree.SubElement(el, "marker") 105 | if marker.name: 106 | ElementTree.SubElement(markerElement, "name").text = marker.name 107 | 108 | if marker.label: 109 | ElementTree.SubElement(markerElement, "label").text = marker.label 110 | 111 | if marker.shape: 112 | ElementTree.SubElement(markerElement, "shape").text = marker.shape 113 | 114 | if marker.color: 115 | ElementTree.SubElement(markerElement, "color").text = marker.color 116 | 117 | if marker.x: 118 | ElementTree.SubElement(markerElement, "x").text = marker.x 119 | 120 | if marker.y: 121 | ElementTree.SubElement(markerElement, "y").text = marker.y 122 | 123 | if marker.contentRef: 124 | contentElement = ElementTree.SubElement(markerElement, "content") 125 | contentElement.set("ref", marker.contentRef) 126 | 127 | if marker.locked: 128 | ElementTree.SubElement(markerElement, "locked").text = marker.locked 129 | 130 | if marker.hidden: 131 | ElementTree.SubElement(markerElement, "hidden").text = marker.hidden 132 | 133 | # encounters 134 | for encounter in self.encounters: 135 | 136 | el = ElementTree.SubElement(module, "encounter") 137 | el.set("id", encounter.id) 138 | 139 | if encounter.parent != None: 140 | el.set("parent", encounter.parent.id) 141 | 142 | ElementTree.SubElement(el, "name").text = encounter.name 143 | ElementTree.SubElement(el, "slug").text = encounter.slug 144 | 145 | # combatants 146 | for combatant in encounter.combatants: 147 | combatantElement = ElementTree.SubElement(el, "combatant") 148 | if combatant.name: 149 | ElementTree.SubElement(combatantElement, "name").text = combatant.name 150 | 151 | if combatant.label: 152 | ElementTree.SubElement(combatantElement, "label").text = combatant.label 153 | 154 | if combatant.role: 155 | ElementTree.SubElement(combatantElement, "role").text = combatant.role 156 | 157 | if combatant.name: 158 | ElementTree.SubElement(combatantElement, "name").text = combatant.name 159 | 160 | if combatant.x and combatant.x != "0": 161 | ElementTree.SubElement(combatantElement, "x").text = combatant.x 162 | 163 | if combatant.y and combatant.y != "0": 164 | ElementTree.SubElement(combatantElement, "y").text = combatant.y 165 | 166 | if combatant.monsterRef: 167 | monsterElement = ElementTree.SubElement(combatantElement, "monster") 168 | monsterElement.set("ref", combatant.monsterRef) 169 | 170 | tree = ElementTree.ElementTree(module) 171 | tree.write(path, encoding="utf-8", xml_declaration=True) 172 | 173 | def create_archive(src, name): 174 | # copy assets 175 | logger.debug("copying assets") 176 | current_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 177 | assets_src = os.path.join(current_dir, "assets") 178 | assets_dst = os.path.join(src, "assets") 179 | 180 | # remove exising assets dir 181 | if os.path.exists(assets_dst): 182 | shutil.rmtree(assets_dst) 183 | 184 | # copy assets 185 | shutil.copytree(assets_src, assets_dst) 186 | 187 | # create archive 188 | logger.info("creating archive") 189 | parent_dir = os.path.dirname(src) 190 | archive_file = os.path.join(parent_dir, name) 191 | 192 | shutil.make_archive(archive_file, 'zip', src) 193 | 194 | # rename 195 | os.rename(archive_file + ".zip", archive_file + ".module") -------------------------------------------------------------------------------- /parsers/fantasygrounds.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import uuid 4 | import shutil 5 | 6 | from xml.etree import ElementTree 7 | from natsort import natsorted, humansorted 8 | from parsers import Parser 9 | from models import Group, Page, Map, Marker, Encounter, Combatant, Module 10 | from slugify import slugify 11 | 12 | import logging 13 | logger = logging.getLogger(__name__) 14 | 15 | # class helpers 16 | class Image: 17 | tag = None 18 | name = None 19 | bitmap = None 20 | 21 | class NPC: 22 | tag = None 23 | name = None 24 | 25 | class FantasyGrounds(Parser): 26 | 27 | def parse_xml(self, path, module): 28 | logger.debug("parsing xml: %s", path) 29 | 30 | # lookup tables 31 | lookup = {} 32 | lookup["encounter"] = {} 33 | lookup["page"] = {} 34 | lookup["map"] = {} 35 | lookup["image"] = {} 36 | lookup["npc"] = {} 37 | lookup["quest"] = {} 38 | 39 | # arrays 40 | pages = [] 41 | maps = [] 42 | groups = [] 43 | encounters = [] 44 | 45 | # xml tree 46 | tree = ElementTree.parse(path) 47 | root = tree.getroot() 48 | 49 | # NPCS 50 | logger.info("parsing npcs") 51 | 52 | for category in root.findall("./npc/category"): 53 | for node in category.findall("*"): 54 | tag = node.tag 55 | name = node.find("name").text 56 | 57 | npc = NPC() 58 | npc.name = name 59 | lookup["npc"][tag] = npc 60 | 61 | # PAGES 62 | logger.info("parsing pages") 63 | 64 | parent = Group() 65 | parent.name = "Story" 66 | parent.slug = slugify(parent.name) 67 | groups.append(parent) 68 | 69 | for category in root.findall("./encounter/category"): 70 | 71 | group = Group() 72 | group.name = category.get("name") 73 | group.slug = slugify(group.name) 74 | group.parent = parent 75 | 76 | if group.name == None or group.name == "": 77 | group = parent 78 | else: 79 | groups.append(group) 80 | 81 | # get all pages 82 | for node in category.findall("*"): 83 | # tag 84 | tag = node.tag 85 | 86 | # create page 87 | page = Page() 88 | page.meta["tag"] = tag 89 | page.name = node.find("name").text 90 | page.slug = slugify(page.name) 91 | page.content = ElementTree.tostring(node.find("text"), encoding='utf-8', method='xml').decode('utf-8') 92 | page.parent = group 93 | 94 | pages.append(page) 95 | lookup["page"][tag] = page 96 | 97 | # QUESTS 98 | logger.info("parsing quests") 99 | 100 | parent = Group() 101 | parent.name = "Quests" 102 | parent.slug = slugify(parent.name) 103 | groups.append(parent) 104 | 105 | # some modules got, so use this instead 106 | for node in root.findall("./quest/*/*"): 107 | # for node in root.findall("./quest/*"): 108 | # tag 109 | tag = node.tag 110 | 111 | # create quest 112 | page = Page() 113 | page.meta["tag"] = id 114 | page.name = node.find("name").text 115 | page.slug = slugify(page.name) 116 | 117 | page.content = ElementTree.tostring(node.find("description"), encoding='utf-8', method='xml').decode('utf-8') 118 | 119 | cr = node.find("cr").text if node.find("cr") else "" 120 | xp = node.find("xp").text if node.find("xp") else "" 121 | 122 | page.content += '

CR: ' + cr + ' XP: ' + xp + '

' 123 | page.parent = parent 124 | 125 | pages.append(page) 126 | lookup["quest"][tag] = page 127 | 128 | # sort 129 | pages_sorted = humansorted(pages, key=lambda x: x.name) 130 | 131 | # MAPS & IMAGES 132 | logger.info("parsing images and maps") 133 | 134 | parent = Group() 135 | parent.name = "Maps & Images" 136 | parent.slug = slugify(parent.name) 137 | groups.append(parent) 138 | 139 | for category in root.findall("./image/category"): 140 | group = Group() 141 | group.name = category.get("name") 142 | group.slug = slugify(group.name) 143 | group.parent = parent 144 | 145 | if group.name == None or group.name == "": 146 | group = parent 147 | else: 148 | groups.append(group) 149 | 150 | for node in category.findall("*"): 151 | # tag 152 | tag = node.tag 153 | 154 | # create image 155 | image = Image() 156 | image.tag = tag 157 | image.bitmap = node.find("./image/bitmap").text.replace("\\", "/") 158 | image.name = node.find("name").text 159 | 160 | lookup["image"][tag] = image 161 | 162 | markers = [] 163 | 164 | # get shortcouts (markers) 165 | for shortcut in node.findall("./image/shortcuts/shortcut"): 166 | # create marker 167 | marker = Marker() 168 | marker.x = shortcut.find("x").text 169 | marker.y = shortcut.find("y").text 170 | 171 | shortcut_ref = shortcut.find("recordname").text.replace("encounter.", "").replace("@*", "") 172 | page = None 173 | if shortcut_ref in lookup["page"]: 174 | page = lookup["page"][shortcut_ref] 175 | 176 | # remove chapter numbers from page name 177 | # maybe use a regex? 178 | name = page.name 179 | if " " in page.name: 180 | first, second = page.name.split(' ', 1) 181 | if "." in first: 182 | name = second 183 | 184 | marker.name = name 185 | marker.contentRef = "/page/" + page.slug 186 | 187 | markers.append(marker) 188 | 189 | if markers or node.find("./image/gridsize") != None: 190 | # if markers not empty, its a map 191 | map = Map() 192 | map.parent = group 193 | map.meta["tag"] = tag 194 | map.name = image.name 195 | map.slug = slugify(map.name) 196 | map.image = image.bitmap 197 | if node.find("./image/gridsize") != None: 198 | map.gridSize = node.find("./image/gridsize").text 199 | if node.find("./image/gridoffset") != None: 200 | gridOffset = node.find("./image/gridoffset").text 201 | map.gridOffsetX = gridOffset.split(",")[0] 202 | map.gridOffsetY = gridOffset.split(",")[1] 203 | map.markers = markers 204 | 205 | maps.append(map) 206 | lookup["map"][tag] = map 207 | else: 208 | # otherwise, its a image 209 | page = Page() 210 | page.parent = group 211 | page.meta["tag"] = tag 212 | page.name = image.name 213 | page.slug = slugify(page.name) 214 | page.content = '

' 215 | 216 | pages_sorted.append(page) 217 | # do not add to lookup tables 218 | 219 | # sort 220 | maps_sorted = humansorted(maps, key=lambda x: x.name) 221 | 222 | # ENCOUNTERS 223 | logger.info("parsing encounters") 224 | 225 | parent = Group() 226 | parent.name = "Encounters" 227 | parent.slug = slugify(parent.name) 228 | groups.append(parent) 229 | 230 | for category in root.findall("./battle/category"): 231 | group = Group() 232 | group.name = category.get("name") 233 | group.slug = slugify(group.name) 234 | group.parent = parent 235 | 236 | if group.name == None or group.name == "": 237 | group = parent 238 | else: 239 | groups.append(group) 240 | 241 | for node in category.findall("*"): 242 | # tag 243 | tag = node.tag 244 | 245 | # create encounter 246 | encounter = Encounter() 247 | encounter.meta["tag"] = tag 248 | encounter.parent = group 249 | 250 | encounter.name = node.find("name").text 251 | encounter.slug = slugify(encounter.name) 252 | 253 | encounters.append(encounter) 254 | lookup["encounter"][tag] = encounter 255 | 256 | # get combatants 257 | for npcnode in node.find("npclist").findall("*"): 258 | 259 | # get positions 260 | maplinks = npcnode.findall("./maplink/*") 261 | 262 | # combatants count 263 | count = int(npcnode.find("count").text) 264 | 265 | # iterate 266 | for x in range(count): 267 | combatant = Combatant() 268 | combatant.name = npcnode.find("name").text 269 | encounter.combatants.append(combatant) 270 | 271 | # if position on map 272 | if len(maplinks) == count: 273 | maplinknode = maplinks[x] 274 | 275 | if maplinknode.find("./imagex") != None: 276 | combatant.x = maplinknode.find("./imagex").text 277 | 278 | if maplinknode.find("./imagey") != None: 279 | combatant.y = maplinknode.find("./imagey").text 280 | 281 | 282 | 283 | encounters_sorted = humansorted(encounters, key=lambda x: x.name) 284 | 285 | # custom regex for processing links 286 | def href_replace(match): 287 | key = str(match.group(2)).split("@")[0] 288 | 289 | type = match.group(1) 290 | 291 | if type == "image" and key in lookup["map"]: 292 | return 'href="/map/' + lookup["map"][key].slug 293 | elif type == "image" and key in lookup["image"]: 294 | return 'href="' + lookup["image"][key].bitmap 295 | elif type == "encounter" and key in lookup["page"]: 296 | return 'href="' + lookup["page"][key].slug 297 | elif type == "battle" and key in lookup["encounter"]: 298 | return 'href="/encounter/' + lookup["encounter"][key].slug 299 | elif type == "quest" and key in lookup["quest"]: 300 | return 'href="' + lookup["quest"][key].slug 301 | else: 302 | return key 303 | 304 | # fix content tags in pages 305 | for page in pages_sorted: 306 | content = page.content 307 | # maybe regex 308 | content = content.replace('', '').replace('', '').replace('', '') 309 | content = content.replace('', '').replace('', '').replace('', '') 310 | content = content.replace('', '
').replace('', '
') 311 | content = content.replace('DM', '') 312 | content = content.replace('\r', '
') 313 | content = content.replace('', '

').replace('', '

') 314 | content = content.replace('', '
    ').replace('', '
') 315 | # content = content.replace("", "
    ").replace("", "
") 316 | content = content.replace('', '').replace('', '') 317 | content = content.replace('', '

') 318 | content = content.replace(' recordname', ' href') 319 | content = content.strip() 320 | 321 | # fix links 322 | content = re.sub(r'href=[\'"]?(encounter|battle|image|quest)\.([^\'">]+)', href_replace, content) 323 | 324 | # add title 325 | if content.startswith('

'): 326 | page.content = content.replace('

', '

', 1).replace('

', '', 1) 327 | else: 328 | page.content = '

' + page.name + '

' + content 329 | 330 | # assign data to module 331 | module.groups = groups 332 | module.pages = pages_sorted 333 | module.maps = maps_sorted 334 | module.encounters = encounters_sorted 335 | 336 | return module 337 | 338 | def process_mod(self, path, module): 339 | # path info 340 | basename = os.path.basename(path) 341 | dirname = os.path.dirname(path) 342 | unpacked_dir = os.path.join(dirname, module.slug) 343 | 344 | # unpack archive 345 | logger.info("unpacking archive: %s", basename) 346 | shutil.unpack_archive(path, unpacked_dir, "zip") 347 | 348 | # convert db.xml to module.xml 349 | xml_file = os.path.join(unpacked_dir, "db.xml") 350 | if os.path.exists(xml_file): 351 | # parse data 352 | self.parse_xml(xml_file, module) 353 | 354 | # create dst 355 | dst = os.path.join(unpacked_dir, "module.xml") 356 | 357 | # export xml 358 | module.export_xml(dst) 359 | 360 | else: 361 | raise ValueError("db.xml not found") 362 | 363 | # create archive 364 | Module.create_archive(unpacked_dir, module.slug) 365 | 366 | return module 367 | 368 | 369 | def process(self, path, module): 370 | # path info 371 | basename = os.path.basename(path) 372 | ext = os.path.splitext(basename)[1] 373 | 374 | # file check 375 | if os.path.isfile(path) == False: 376 | raise ValueError('Path must be a file') 377 | 378 | # extension check 379 | if ext == ".mod": 380 | # .mod file 381 | self.process_mod(path, module) 382 | elif ext == ".xml": 383 | # .xml file 384 | self.parse_xml(path, module) 385 | else: 386 | raise ValueError('Invalid path') 387 | 388 | -------------------------------------------------------------------------------- /ddbtoxml.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/python3 2 | import os 3 | import sys 4 | import json 5 | import math 6 | import uuid 7 | import requests 8 | import tempfile 9 | import shutil 10 | import re 11 | import base64 12 | from json import JSONDecodeError 13 | 14 | def getJSON(theurl): 15 | rawjson = "" 16 | if theurl.startswith("https://www.dndbeyond.com/"): 17 | #Pretend to be firefox 18 | user_agent = "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:70.0) Gecko/20100101 Firefox/70.0" 19 | headers = {'User-Agent': user_agent} 20 | urlcomponents = theurl.split("/") 21 | charid = urlcomponents[-1] 22 | if charid == "json": 23 | charid = urlcomponents[-2] 24 | url = "https://www.dndbeyond.com/character/{}/json".format(charid) 25 | response = requests.get(url,headers=headers) 26 | if response.status_code != 200: 27 | print (theurl) 28 | print ("Could not download this character from D&D Beyond: {}".format(response.status_code)) 29 | print ("Make sure the character is public") 30 | return 31 | else: 32 | if "character" in response.json(): 33 | character = response.json()["character"] 34 | else: 35 | character = response.json() 36 | return character 37 | else: 38 | print ("This is not a url for D&D Beyond: {}".format(theurl)) 39 | return 40 | 41 | def genXML(character): 42 | level = 0 43 | characterXML = "\t\n" 44 | characterXML += "\t\t{}\n".format(character["name"]) 45 | #characterXML += "\t\tddb-{}\n".format(character["id"]) 46 | if len(character["classes"]) > 1: 47 | allclasses = [] 48 | for cclass in character["classes"]: 49 | level += cclass["level"] 50 | allclasses.append("{} {}".format(cclass["definition"]["name"],cclass["level"])) 51 | characterXML += "\t\t{}\n".format('/'.join(allclasses)) 52 | else: 53 | characterclass = character["classes"][0]["definition"]["name"] 54 | level = character["classes"][0]["level"] 55 | characterXML += "\t\t{}\n".format(characterclass) 56 | characterXML += "\t\t{}\n".format(level) 57 | characterXML += "\t\t{}\n".format(character["currentXp"]) 58 | hitpoints = character["baseHitPoints"] 59 | armorclass = 0 60 | stat_str = character["stats"][0]["value"] 61 | stat_dex = character["stats"][1]["value"] 62 | stat_con = character["stats"][2]["value"] 63 | stat_int = character["stats"][3]["value"] 64 | stat_wis = character["stats"][4]["value"] 65 | stat_cha = character["stats"][5]["value"] 66 | race = character["race"]["fullName"] 67 | speed = character["race"]["weightSpeeds"]["normal"]["walk"] 68 | modifiers = character["modifiers"] 69 | senses = [] 70 | for modifier in (modifiers["race"]+modifiers["class"]+modifiers["background"]+modifiers["item"]+modifiers["feat"]+modifiers["condition"]): 71 | if modifier["isGranted"] == True and modifier["type"].lower() == "bonus": 72 | if modifier["subType"].lower() == "strength-score": 73 | stat_str += modifier["value"] 74 | elif modifier["subType"].lower() == "dexterity-score": 75 | stat_dex += modifier["value"] 76 | elif modifier["subType"].lower() == "constitution-score": 77 | stat_con += modifier["value"] 78 | elif modifier["subType"].lower() == "inteligence-score": 79 | stat_int += modifier["value"] 80 | elif modifier["subType"].lower() == "wisdom-score": 81 | stat_wis += modifier["value"] 82 | elif modifier["subType"].lower() == "charisma-score": 83 | stat_cha += modifier["value"] 84 | 85 | hitpoints += math.floor((stat_con - 10)/2)*level 86 | initiative = math.floor((stat_dex - 10)/2) 87 | equipment = [] 88 | for equip in character["inventory"]: 89 | # for i in range(equip["quantity"]): 90 | # equipment.append(equip["definition"]["name"]) 91 | # if equip["quantity"] > 1: 92 | # equipment.append("{} (x{:d})".format(equip["definition"]["name"],equip["quantity"])) 93 | # else: 94 | # equipment.append(equip["definition"]["name"]) 95 | if "armor" in equip["definition"]["type"].lower() and "armor" not in equip["definition"]["name"].lower(): 96 | equipment.append(equip["definition"]["name"] + " Armor") 97 | else: 98 | equipment.append(equip["definition"]["name"]) 99 | if equip["equipped"] == True and "armorClass" in equip["definition"]: 100 | armorclass += equip["definition"]["armorClass"] 101 | if armorclass == 0: 102 | armorclass = 10 103 | armorclass += math.floor((stat_dex - 10)/2) 104 | light = "" 105 | languages = [] 106 | resistence = [] 107 | immunity = [] 108 | skill = {} 109 | str_save = math.floor((stat_str - 10)/2) 110 | skill["Athletics"] = str_save 111 | dex_save = math.floor((stat_dex - 10)/2) 112 | skill["Acrobatics"] = dex_save 113 | skill["Sleight of Hand"] = dex_save 114 | skill["Stealth"] = dex_save 115 | con_save = math.floor((stat_con - 10)/2) 116 | int_save = math.floor((stat_int - 10)/2) 117 | skill["Arcana"] = int_save 118 | skill["History"] = int_save 119 | skill["Investigation"] = int_save 120 | skill["Nature"] = int_save 121 | skill["Religion"] = int_save 122 | wis_save = math.floor((stat_wis - 10)/2) 123 | skill["Animal Handling"] = wis_save 124 | skill["Insight"] = wis_save 125 | skill["Medicine"] = wis_save 126 | skill["Perception"] = wis_save 127 | skill["Survival"] = wis_save 128 | cha_save = math.floor((stat_cha - 10)/2) 129 | skill["Deception"] = cha_save 130 | skill["Intimidation"] = cha_save 131 | skill["Performance"] = cha_save 132 | skill["Persuasion"] = cha_save 133 | for modifier in (modifiers["race"]+modifiers["class"]+modifiers["background"]+modifiers["item"]+modifiers["feat"]+modifiers["condition"]): 134 | if modifier["type"].lower() == "half-proficiency": 135 | bonus = math.ceil(((level/4)+1)/2) 136 | if modifier["subType"].lower() == "athletics" or modifier["subType"].lower() == "ability-checks": 137 | skill["Athletics"] = math.floor((stat_str - 10)/2) + bonus 138 | if modifier["subType"].lower() == "acrobatics" or modifier["subType"].lower() == "ability-checks": 139 | skill["Acrobatics"] = math.floor((stat_dex - 10)/2) + bonus 140 | if modifier["subType"].lower() == "sleight-of-hand" or modifier["subType"].lower() == "ability-checks": 141 | skill["Sleight of Hand"] = math.floor((stat_dex - 10)/2) + bonus 142 | if modifier["subType"].lower() == "stealth" or modifier["subType"].lower() == "ability-checks": 143 | skill["Stealth"] = math.floor((stat_dex - 10)/2) + bonus 144 | if modifier["subType"].lower() == "arcana" or modifier["subType"].lower() == "ability-checks": 145 | skill["Arcana"] = math.floor((stat_int - 10)/2) + bonus 146 | if modifier["subType"].lower() == "history" or modifier["subType"].lower() == "ability-checks": 147 | skill["History"] = math.floor((stat_int - 10)/2) + bonus 148 | if modifier["subType"].lower() == "investigation" or modifier["subType"].lower() == "ability-checks": 149 | skill["Investigation"] = math.floor((stat_int - 10)/2) + bonus 150 | if modifier["subType"].lower() == "nature" or modifier["subType"].lower() == "ability-checks": 151 | skill["Nature"] = math.floor((stat_int - 10)/2) + bonus 152 | if modifier["subType"].lower() == "religion" or modifier["subType"].lower() == "ability-checks": 153 | skill["Religion"] = math.floor((stat_int - 10)/2) + bonus 154 | if modifier["subType"].lower() == "animal-handling" or modifier["subType"].lower() == "ability-checks": 155 | skill["Animal Handling"] = math.floor((stat_wis - 10)/2) + bonus 156 | if modifier["subType"].lower() == "insight" or modifier["subType"].lower() == "ability-checks": 157 | skill["Insight"] = math.floor((stat_wis - 10)/2) + bonus 158 | if modifier["subType"].lower() == "medicine" or modifier["subType"].lower() == "ability-checks": 159 | skill["Medicine"] = math.floor((stat_wis - 10)/2) + bonus 160 | if modifier["subType"].lower() == "perception" or modifier["subType"].lower() == "ability-checks": 161 | skill["Perception"] = math.floor((stat_wis - 10)/2) + bonus 162 | if modifier["subType"].lower() == "survival" or modifier["subType"].lower() == "ability-checks": 163 | skill["Survival"] = math.floor((stat_wis - 10)/2) + bonus 164 | if modifier["subType"].lower() == "deception" or modifier["subType"].lower() == "ability-checks": 165 | skill["Deception"] = math.floor((stat_cha - 10)/2) + bonus 166 | if modifier["subType"].lower() == "intimidation" or modifier["subType"].lower() == "ability-checks": 167 | skill["Intimidation"] = math.floor((stat_cha - 10)/2) + bonus 168 | if modifier["subType"].lower() == "performance" or modifier["subType"].lower() == "ability-checks": 169 | skill["Performance"] = math.floor((stat_cha - 10)/2) + bonus 170 | if modifier["subType"].lower() == "persuasion" or modifier["subType"].lower() == "ability-checks": 171 | skill["Persuasion"] = math.floor((stat_cha - 10)/2) + bonus 172 | if modifier["subType"].lower() == "initiative": 173 | initiative = math.floor((stat_dex - 10)/2) + bonus 174 | if modifier["subType"].lower() == "strength-saving-throws": 175 | str_save = math.floor((stat_str - 10)/2) + bonus 176 | if modifier["subType"].lower() == "dexterity-saving-throws": 177 | dex_save = math.floor((stat_dex - 10)/2) + bonus 178 | if modifier["subType"].lower() == "constitution-saving-throws": 179 | con_save = math.floor((stat_con - 10)/2) + bonus 180 | if modifier["subType"].lower() == "inteligence-saving-throws": 181 | int_save = math.floor((stat_int - 10)/2) + bonus 182 | if modifier["subType"].lower() == "wisdom-saving-throws": 183 | wis_save = math.floor((stat_wis - 10)/2) + bonus 184 | if modifier["subType"].lower() == "charisma-saving-throws": 185 | cha_save = math.floor((stat_cha - 10)/2) + bonus 186 | for modifier in (modifiers["race"]+modifiers["class"]+modifiers["background"]+modifiers["item"]+modifiers["feat"]+modifiers["condition"]): 187 | if modifier["type"].lower() == "proficiency": 188 | bonus = math.ceil((level/4)+1) 189 | if modifier["subType"].lower() == "athletics" or modifier["subType"].lower() == "ability-checks": 190 | skill["Athletics"] = math.floor((stat_str - 10)/2) + bonus 191 | if modifier["subType"].lower() == "acrobatics" or modifier["subType"].lower() == "ability-checks": 192 | skill["Acrobatics"] = math.floor((stat_dex - 10)/2) + bonus 193 | if modifier["subType"].lower() == "sleight-of-hand" or modifier["subType"].lower() == "ability-checks": 194 | skill["Sleight of Hand"] = math.floor((stat_dex - 10)/2) + bonus 195 | if modifier["subType"].lower() == "stealth" or modifier["subType"].lower() == "ability-checks": 196 | skill["Stealth"] = math.floor((stat_dex - 10)/2) + bonus 197 | if modifier["subType"].lower() == "arcana" or modifier["subType"].lower() == "ability-checks": 198 | skill["Arcana"] = math.floor((stat_int - 10)/2) + bonus 199 | if modifier["subType"].lower() == "history" or modifier["subType"].lower() == "ability-checks": 200 | skill["History"] = math.floor((stat_int - 10)/2) + bonus 201 | if modifier["subType"].lower() == "investigation" or modifier["subType"].lower() == "ability-checks": 202 | skill["Investigation"] = math.floor((stat_int - 10)/2) + bonus 203 | if modifier["subType"].lower() == "nature" or modifier["subType"].lower() == "ability-checks": 204 | skill["Nature"] = math.floor((stat_int - 10)/2) + bonus 205 | if modifier["subType"].lower() == "religion" or modifier["subType"].lower() == "ability-checks": 206 | skill["Religion"] = math.floor((stat_int - 10)/2) + bonus 207 | if modifier["subType"].lower() == "animal-handling" or modifier["subType"].lower() == "ability-checks": 208 | skill["Animal Handling"] = math.floor((stat_wis - 10)/2) + bonus 209 | if modifier["subType"].lower() == "insight" or modifier["subType"].lower() == "ability-checks": 210 | skill["Insight"] = math.floor((stat_wis - 10)/2) + bonus 211 | if modifier["subType"].lower() == "medicine" or modifier["subType"].lower() == "ability-checks": 212 | skill["Medicine"] = math.floor((stat_wis - 10)/2) + bonus 213 | if modifier["subType"].lower() == "perception" or modifier["subType"].lower() == "ability-checks": 214 | skill["Perception"] = math.floor((stat_wis - 10)/2) + bonus 215 | if modifier["subType"].lower() == "survival" or modifier["subType"].lower() == "ability-checks": 216 | skill["Survival"] = math.floor((stat_wis - 10)/2) + bonus 217 | if modifier["subType"].lower() == "deception" or modifier["subType"].lower() == "ability-checks": 218 | skill["Deception"] = math.floor((stat_cha - 10)/2) + bonus 219 | if modifier["subType"].lower() == "intimidation" or modifier["subType"].lower() == "ability-checks": 220 | skill["Intimidation"] = math.floor((stat_cha - 10)/2) + bonus 221 | if modifier["subType"].lower() == "performance" or modifier["subType"].lower() == "ability-checks": 222 | skill["Performance"] = math.floor((stat_cha - 10)/2) + bonus 223 | if modifier["subType"].lower() == "persuasion" or modifier["subType"].lower() == "ability-checks": 224 | skill["Persuasion"] = math.floor((stat_cha - 10)/2) + bonus 225 | if modifier["subType"].lower() == "initiative": 226 | initiative = math.floor((stat_dex - 10)/2) + bonus 227 | if modifier["subType"].lower() == "strength-saving-throws": 228 | str_save = math.floor((stat_str - 10)/2) + bonus 229 | if modifier["subType"].lower() == "dexterity-saving-throws": 230 | dex_save = math.floor((stat_dex - 10)/2) + bonus 231 | if modifier["subType"].lower() == "constitution-saving-throws": 232 | con_save = math.floor((stat_con - 10)/2) + bonus 233 | if modifier["subType"].lower() == "inteligence-saving-throws": 234 | int_save = math.floor((stat_int - 10)/2) + bonus 235 | if modifier["subType"].lower() == "wisdom-saving-throws": 236 | wis_save = math.floor((stat_wis - 10)/2) + bonus 237 | if modifier["subType"].lower() == "charisma-saving-throws": 238 | cha_save = math.floor((stat_cha - 10)/2) + bonus 239 | for modifier in (modifiers["race"]+modifiers["class"]+modifiers["background"]+modifiers["item"]+modifiers["feat"]+modifiers["condition"]): 240 | if modifier["type"].lower() == "expertise": 241 | bonus = math.ceil(((level/4)+1)*2) 242 | if modifier["subType"].lower() == "athletics" or modifier["subType"].lower() == "ability-checks": 243 | skill["Athletics"] = math.floor((stat_str - 10)/2) + bonus 244 | if modifier["subType"].lower() == "acrobatics" or modifier["subType"].lower() == "ability-checks": 245 | skill["Acrobatics"] = math.floor((stat_dex - 10)/2) + bonus 246 | if modifier["subType"].lower() == "sleight-of-hand" or modifier["subType"].lower() == "ability-checks": 247 | skill["Sleight of Hand"] = math.floor((stat_dex - 10)/2) + bonus 248 | if modifier["subType"].lower() == "stealth" or modifier["subType"].lower() == "ability-checks": 249 | skill["Stealth"] = math.floor((stat_dex - 10)/2) + bonus 250 | if modifier["subType"].lower() == "arcana" or modifier["subType"].lower() == "ability-checks": 251 | skill["Arcana"] = math.floor((stat_int - 10)/2) + bonus 252 | if modifier["subType"].lower() == "history" or modifier["subType"].lower() == "ability-checks": 253 | skill["History"] = math.floor((stat_int - 10)/2) + bonus 254 | if modifier["subType"].lower() == "investigation" or modifier["subType"].lower() == "ability-checks": 255 | skill["Investigation"] = math.floor((stat_int - 10)/2) + bonus 256 | if modifier["subType"].lower() == "nature" or modifier["subType"].lower() == "ability-checks": 257 | skill["Nature"] = math.floor((stat_int - 10)/2) + bonus 258 | if modifier["subType"].lower() == "religion" or modifier["subType"].lower() == "ability-checks": 259 | skill["Religion"] = math.floor((stat_int - 10)/2) + bonus 260 | if modifier["subType"].lower() == "animal-handling" or modifier["subType"].lower() == "ability-checks": 261 | skill["Animal Handling"] = math.floor((stat_wis - 10)/2) + bonus 262 | if modifier["subType"].lower() == "insight" or modifier["subType"].lower() == "ability-checks": 263 | skill["Insight"] = math.floor((stat_wis - 10)/2) + bonus 264 | if modifier["subType"].lower() == "medicine" or modifier["subType"].lower() == "ability-checks": 265 | skill["Medicine"] = math.floor((stat_wis - 10)/2) + bonus 266 | if modifier["subType"].lower() == "perception" or modifier["subType"].lower() == "ability-checks": 267 | skill["Perception"] = math.floor((stat_wis - 10)/2) + bonus 268 | if modifier["subType"].lower() == "survival" or modifier["subType"].lower() == "ability-checks": 269 | skill["Survival"] = math.floor((stat_wis - 10)/2) + bonus 270 | if modifier["subType"].lower() == "deception" or modifier["subType"].lower() == "ability-checks": 271 | skill["Deception"] = math.floor((stat_cha - 10)/2) + bonus 272 | if modifier["subType"].lower() == "intimidation" or modifier["subType"].lower() == "ability-checks": 273 | skill["Intimidation"] = math.floor((stat_cha - 10)/2) + bonus 274 | if modifier["subType"].lower() == "performance" or modifier["subType"].lower() == "ability-checks": 275 | skill["Performance"] = math.floor((stat_cha - 10)/2) + bonus 276 | if modifier["subType"].lower() == "persuasion" or modifier["subType"].lower() == "ability-checks": 277 | skill["Persuasion"] = math.floor((stat_cha - 10)/2) + bonus 278 | if modifier["subType"].lower() == "initiative": 279 | initiative = math.floor((stat_dex - 10)/2) + bonus 280 | if modifier["subType"].lower() == "strength-saving-throws": 281 | str_save = math.floor((stat_str - 10)/2) + bonus 282 | if modifier["subType"].lower() == "dexterity-saving-throws": 283 | dex_save = math.floor((stat_dex - 10)/2) + bonus 284 | if modifier["subType"].lower() == "constitution-saving-throws": 285 | con_save = math.floor((stat_con - 10)/2) + bonus 286 | if modifier["subType"].lower() == "inteligence-saving-throws": 287 | int_save = math.floor((stat_int - 10)/2) + bonus 288 | if modifier["subType"].lower() == "wisdom-saving-throws": 289 | wis_save = math.floor((stat_wis - 10)/2) + bonus 290 | if modifier["subType"].lower() == "charisma-saving-throws": 291 | cha_save = math.floor((stat_cha - 10)/2) + bonus 292 | for modifier in (modifiers["race"]+modifiers["class"]+modifiers["background"]+modifiers["item"]+modifiers["feat"]+modifiers["condition"]): 293 | if modifier["type"].lower() == "set" and modifier["subType"].lower() == "unarmored-armor-class": 294 | if modifier["statId"] == 3: 295 | armorclass += math.floor((stat_con - 10)/2) 296 | if modifier["value"] is not None: 297 | armorclass += modifier["value"] 298 | if modifier["type"].lower() == "ignore" and modifier["subType"].lower() == "unarmored-dex-ac-bonus": 299 | armorclass -= math.floor((stat_dex - 10)/2) 300 | if modifier["type"].lower() == "set-base" and modifier["subType"].lower() == "darkvision": 301 | senses.append("{} {} ft.".format(modifier["subType"].lower(),modifier["value"])) 302 | light = "\t\t\n\t\t\tYES\n\t\t\t0\n\t\t\t{}\n\t\t\t#ffffff\n\t\t\t0.5\n\t\t\tYES\n\t\t\n".format(uuid.uuid4(),modifier["value"]) 303 | if modifier["type"].lower() == "language": 304 | languages.append(modifier["friendlySubtypeName"]) 305 | if modifier["type"].lower() == "resistance": 306 | resistence.append(modifier["friendlySubtypeName"]) 307 | if modifier["type"].lower() == "immunity": 308 | immunity.append(modifier["friendlySubtypeName"]) 309 | spells = [] 310 | for spell in character["spells"]["race"]: 311 | spells.append(spell["definition"]["name"]) 312 | for spell in character["spells"]["class"]: 313 | spells.append(spell["definition"]["name"]) 314 | for spell in character["spells"]["item"]: 315 | spells.append(spell["definition"]["name"]) 316 | for spell in character["spells"]["feat"]: 317 | spells.append(spell["definition"]["name"]) 318 | for classsp in character["classSpells"]: 319 | for spell in classsp["spells"]: 320 | spells.append(spell["definition"]["name"]) 321 | party = "" 322 | if "campaign" in character and character["campaign"] is not None: 323 | party = character["campaign"]["name"] 324 | background = "" 325 | if "background" in character and character["background"] is not None and character["background"]["definition"] is not None: 326 | background = character["background"]["definition"]["name"] 327 | bg_def = character["background"]["definition"] 328 | feats = [] 329 | for feat in character["feats"]: 330 | feats.append(feat["definition"]["name"]) 331 | feat_def = feat["definition"] 332 | personality = character["traits"]["personalityTraits"] 333 | bonds = character["traits"]["bonds"] 334 | ideals = character["traits"]["ideals"] 335 | flaws = character["traits"]["flaws"] 336 | appearance = character["traits"]["appearance"] 337 | if appearance is None: 338 | appearance = "" 339 | characterXML += "\t\t{}\n".format(race) 340 | characterXML += "\t\t{}\n".format(initiative) 341 | characterXML += "\t\t{}\n".format(armorclass) 342 | characterXML += "\t\t{}\n".format(hitpoints) 343 | characterXML += "\t\t{}\n".format(speed) 344 | characterXML += "\t\t{}\n".format(stat_str) 345 | characterXML += "\t\t{}\n".format(stat_dex) 346 | characterXML += "\t\t{}\n".format(stat_con) 347 | characterXML += "\t\t{}\n".format(stat_int) 348 | characterXML += "\t\t{}\n".format(stat_wis) 349 | characterXML += "\t\t{}\n".format(stat_cha) 350 | characterXML += "\t\t{}\n<i><a href="https://www.dndbeyond.com/profile/username/characters/{}">Imported from D&D Beyond</a></i>\n".format(appearance,character["id"]) 351 | characterXML += "\t\t{}\n".format(party) 352 | characterXML += "\t\t{}\n".format("") 353 | characterXML += "\t\t{}\n".format(skill["Perception"]+10) 354 | characterXML += "\t\t{}\n".format(", ".join(spells)) 355 | characterXML += "\t\t{}\n".format(", ".join(senses)) 356 | characterXML += "\t\t{}\n".format(", ".join(languages)) 357 | characterXML += "\t\t{}\n".format(", ".join(equipment)) 358 | characterXML += "\t\t{}\n".format(character["avatarUrl"].split('/')[-1]) 359 | characterXML += "\t\t{}\n".format(personality) 360 | characterXML += "\t\t{}\n".format(ideals) 361 | characterXML += "\t\t{}\n".format(bonds) 362 | characterXML += "\t\t{}\n".format(flaws) 363 | skills = [] 364 | for sk in sorted(skill.keys()): 365 | skills.append("{} {:+d}".format(sk,skill[sk])) 366 | characterXML += "\t\t{}\n".format(", ".join(skills)) 367 | characterXML += "\t\tStr {:+d}, Dex {:+d}, Con {:+d}, Int {:+d}, Wis {:+d}, Cha {:+d}\n".format(str_save,dex_save,con_save,int_save,wis_save,cha_save) 368 | characterXML += "\t\t{}\n".format(", ".join(resistence)) 369 | characterXML += "\t\t{}\n".format(", ".join(immunity)) 370 | characterXML += "\t\t{}\n".format(background) 371 | characterXML += "\t\t{}\n".format(", ".join(feats)) 372 | if light != "": 373 | characterXML += light 374 | characterXML += "\t\n" 375 | return characterXML 376 | 377 | def findURLS(fp): 378 | fp.seek(0, 0) 379 | characters = [] 380 | regex = re.compile("]*href=\"(/profile/.*/[0-9]+)\"[^>]*class=\"ddb-campaigns-character-card-header-upper-details-link\"[^>]*>") 381 | for line in fp: 382 | m = regex.search(line) 383 | if m: 384 | characterurl = m.group(1) 385 | if not characterurl.startswith("https://www.dndbeyond.com/"): 386 | characters.append("https://www.dndbeyond.com"+characterurl) 387 | else: 388 | characters.append(characterurl) 389 | return characters 390 | 391 | def main(): 392 | tempdir = tempfile.mkdtemp(prefix="ddbtoxml_") 393 | comependiumxml = os.path.join(tempdir, "compendium.xml") 394 | playersdir = os.path.join(tempdir, "players") 395 | os.mkdir(playersdir) 396 | with open(comependiumxml,mode='a',encoding='utf-8') as comependium: 397 | comependium.write("\n\n") 398 | args = sys.argv 399 | if len(args) == 2 and args[1].startswith("--campaign"): 400 | regex = re.compile("]*href=\"(/profile/.*/[0-9]+)\"[^>]*class=\"ddb-campaigns-character-card-header-upper-details-link\"[^>]*>") 401 | if args[1] == "--campaign": 402 | readin = sys.stdin 403 | elif os.path.isfile(args[1][11:]): 404 | readin = open(args[1][11:],'r') 405 | else: 406 | readin = base64.b64decode(args[1][11:]).decode("utf-8").splitlines() 407 | args = [sys.argv[0]] 408 | for line in readin: 409 | m = regex.search(line) 410 | if m: 411 | characterurl = m.group(1) 412 | if not characterurl.startswith("https://www.dndbeyond.com/"): 413 | args.append("https://www.dndbeyond.com"+characterurl) 414 | else: 415 | args.append(characterurl) 416 | try: 417 | readin.close() 418 | except: 419 | pass 420 | characters = [] 421 | for i in range(len(args)): 422 | if args[i] == __file__: 423 | continue 424 | if os.path.isfile(args[i]): 425 | with open(args[i]) as infile: 426 | try: 427 | json.load(infile) 428 | characters.append(args[i]) 429 | except JSONDecodeError: 430 | found = findURLS(infile) 431 | characters.extend(found) 432 | else: 433 | characters.append(args[i]) 434 | if len(characters) == 0: 435 | characters.append("-") 436 | for i in range(len(characters)): 437 | if characters[i] == '-' or os.path.isfile(characters[i]): 438 | if os.path.isfile(characters[i]): 439 | with open(characters[i],"r") as jsonfile: 440 | charjson = json.loads(jsonfile.read()) 441 | else: 442 | charjson = json.loads(sys.stdin.read()) 443 | if "character" in charjson: 444 | character = charjson["character"] 445 | else: 446 | character = charjson 447 | else: 448 | character = getJSON(characters[i]) 449 | if character is not None: 450 | xmloutput = genXML(character) 451 | with open(comependiumxml,mode='a',encoding='utf-8') as comependium: 452 | comependium.write(xmloutput) 453 | if character["avatarUrl"] != "": 454 | local_filename = os.path.join(playersdir,character["avatarUrl"].split('/')[-1]) 455 | r = requests.get(character["avatarUrl"], stream=True) 456 | if r.status_code == 200: 457 | with open(local_filename, 'wb') as f: 458 | for chunk in r.iter_content(chunk_size=8192): 459 | f.write(chunk) 460 | with open(comependiumxml,mode='a',encoding='utf-8') as comependium: 461 | comependium.write("") 462 | 463 | zipfile = shutil.make_archive("ddbxml","zip",tempdir) 464 | os.rename(zipfile,os.path.join(os.getcwd(),"ddbxml.compendium")) 465 | zipfile = os.path.join(os.getcwd(),"ddbxml.compendium") 466 | try: 467 | import console 468 | console.open_in (zipfile) 469 | except ImportError: 470 | print(zipfile) 471 | 472 | try: 473 | shutil.rmtree(tempdir) 474 | except: 475 | print("Warning: error trying to delete the temporal directory:", file=sys.stderr) 476 | print(traceback.format_exc(), file=sys.stderr) 477 | 478 | if __name__== "__main__": 479 | main() 480 | --------------------------------------------------------------------------------