├── 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('', '
', '')
315 | # content = content.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 |
--------------------------------------------------------------------------------