|
1
|
|
|
#!/usr/bin/env python |
|
2
|
|
|
# SPDX-License-Identifier: LGPL-3.0-only |
|
3
|
1 |
|
|
|
4
|
|
|
"""REST server to display content and reserve item numbers.""" |
|
5
|
1 |
|
|
|
6
|
1 |
|
import argparse |
|
7
|
1 |
|
import logging |
|
8
|
1 |
|
import os |
|
9
|
1 |
|
import webbrowser |
|
10
|
|
|
from collections import defaultdict |
|
11
|
1 |
|
from typing import Dict |
|
12
|
1 |
|
|
|
13
|
|
|
import bottle |
|
14
|
1 |
|
from bottle import get, hook, post, request, response, template |
|
15
|
1 |
|
|
|
16
|
1 |
|
from doorstop import Tree, build, common, publisher, settings |
|
17
|
1 |
|
from doorstop.common import HelpFormatter |
|
18
|
1 |
|
from doorstop.core import vcs |
|
19
|
|
|
from doorstop.server import utilities |
|
20
|
1 |
|
|
|
21
|
|
|
log = common.logger(__name__) |
|
22
|
1 |
|
|
|
23
|
1 |
|
app = utilities.StripPathMiddleware(bottle.app()) |
|
24
|
1 |
|
tree: Tree = None # type: ignore, set in `run`, read in the route functions |
|
25
|
|
|
numbers: Dict[str, int] = defaultdict(int) # cache of next document numbers |
|
26
|
|
|
|
|
27
|
1 |
|
|
|
28
|
|
|
def main(args=None): |
|
29
|
1 |
|
"""Process command-line arguments and run the program.""" |
|
30
|
|
|
from doorstop import SERVER, VERSION |
|
31
|
|
|
|
|
32
|
1 |
|
# Shared options |
|
33
|
1 |
|
debug = argparse.ArgumentParser(add_help=False) |
|
34
|
1 |
|
debug.add_argument('-V', '--version', action='version', version=VERSION) |
|
35
|
1 |
|
debug.add_argument('--debug', action='store_true', help=argparse.SUPPRESS) |
|
36
|
1 |
|
debug.add_argument('--launch', action='store_true', help=argparse.SUPPRESS) |
|
37
|
|
|
shared = {'formatter_class': HelpFormatter, 'parents': [debug]} |
|
38
|
|
|
|
|
39
|
1 |
|
# Build main parser |
|
40
|
|
|
parser = argparse.ArgumentParser( # type: ignore |
|
41
|
1 |
|
prog=SERVER, description=__doc__, **shared |
|
42
|
1 |
|
) |
|
43
|
|
|
cwd = os.getcwd() |
|
44
|
1 |
|
|
|
45
|
|
|
parser.add_argument( |
|
46
|
1 |
|
'-j', '--project', default=None, help="path to the root of the project" |
|
47
|
|
|
) |
|
48
|
|
|
parser.add_argument( |
|
49
|
1 |
|
'-P', |
|
50
|
|
|
'--port', |
|
51
|
|
|
metavar='NUM', |
|
52
|
|
|
type=int, |
|
53
|
1 |
|
default=settings.SERVER_PORT, |
|
54
|
|
|
help="use a custom port for the server", |
|
55
|
|
|
) |
|
56
|
1 |
|
parser.add_argument( |
|
57
|
|
|
'-H', '--host', default='127.0.0.1', help="IP address to listen" |
|
58
|
|
|
) |
|
59
|
|
|
parser.add_argument( |
|
60
|
1 |
|
'-w', '--wsgi', action='store_true', help="Run as a WSGI process" |
|
61
|
|
|
) |
|
62
|
|
|
parser.add_argument( |
|
63
|
1 |
|
'-b', |
|
64
|
|
|
'--baseurl', |
|
65
|
|
|
default='', |
|
66
|
|
|
help="Base URL this is served at (Usually only necessary for WSGI)", |
|
67
|
|
|
) |
|
68
|
|
|
|
|
69
|
|
|
# Parse arguments |
|
70
|
|
|
args = parser.parse_args(args=args) |
|
71
|
|
|
|
|
72
|
1 |
|
if args.project is None: |
|
73
|
1 |
|
args.project = vcs.find_root(cwd) |
|
74
|
1 |
|
|
|
75
|
1 |
|
# Configure logging |
|
76
|
1 |
|
logging.basicConfig( |
|
77
|
1 |
|
format=settings.VERBOSE_LOGGING_FORMAT, level=settings.VERBOSE_LOGGING_LEVEL |
|
78
|
1 |
|
) |
|
79
|
1 |
|
|
|
80
|
|
|
# Run the program |
|
81
|
|
|
run(args, os.getcwd(), parser.error) |
|
82
|
|
|
|
|
83
|
1 |
|
|
|
84
|
|
|
def run(args, cwd, _): |
|
85
|
|
|
"""Start the server. |
|
86
|
|
|
|
|
87
|
|
|
:param args: Namespace of CLI arguments (from this module or the CLI) |
|
88
|
|
|
:param cwd: current working directory |
|
89
|
1 |
|
:param error: function to call for CLI errors |
|
90
|
|
|
|
|
91
|
|
|
""" |
|
92
|
1 |
|
global tree |
|
93
|
1 |
|
tree = build(cwd=cwd, root=args.project) |
|
94
|
1 |
|
tree.load() |
|
95
|
|
|
host = args.host |
|
96
|
|
|
port = args.port or settings.SERVER_PORT |
|
97
|
1 |
|
bottle.TEMPLATE_PATH.insert( |
|
98
|
|
|
0, os.path.join(os.path.dirname(__file__), '..', 'views') |
|
99
|
|
|
) |
|
100
|
1 |
|
|
|
101
|
1 |
|
# If you started without WSGI, the base will be '/'. |
|
102
|
1 |
|
if args.baseurl == '' and not args.wsgi: |
|
103
|
1 |
|
args.baseurl = '/' |
|
104
|
|
|
|
|
105
|
1 |
|
# If you specified a base URL, make sure it ends with '/'. |
|
106
|
|
|
if args.baseurl != '' and not args.baseurl.endswith('/'): |
|
107
|
|
|
args.baseurl += '/' |
|
108
|
1 |
|
|
|
109
|
|
|
bottle.SimpleTemplate.defaults['baseurl'] = args.baseurl |
|
110
|
|
|
bottle.SimpleTemplate.defaults['navigation'] = True |
|
111
|
1 |
|
|
|
112
|
1 |
|
if args.launch: |
|
113
|
1 |
|
url = utilities.build_url(host=host, port=port) |
|
114
|
|
|
webbrowser.open(url) |
|
115
|
1 |
|
if not args.wsgi: |
|
116
|
1 |
|
bottle.run(app=app, host=host, port=port, debug=args.debug, reloader=args.debug) |
|
117
|
|
|
|
|
118
|
|
|
|
|
119
|
1 |
|
@hook('before_request') |
|
120
|
|
|
def strip_path(): |
|
121
|
|
|
request.environ['PATH_INFO'] = request.environ['PATH_INFO'].rstrip('/') |
|
122
|
1 |
|
request.environ['PATH_INFO'] = request.environ['PATH_INFO'].rstrip('.html') |
|
123
|
1 |
|
|
|
124
|
1 |
|
|
|
125
|
1 |
|
@hook('after_request') |
|
126
|
|
|
def enable_cors(): |
|
127
|
1 |
|
"""Allow a webserver running on the same machine to access data.""" |
|
128
|
|
|
response.headers['Access-Control-Allow-Origin'] = '*' |
|
129
|
|
|
|
|
130
|
1 |
|
|
|
131
|
|
|
@get('/') |
|
132
|
|
|
def index(): |
|
133
|
1 |
|
"""Read the tree.""" |
|
134
|
1 |
|
yield template('index', tree_code=tree.draw(html_links=True)) |
|
135
|
1 |
|
|
|
136
|
1 |
|
|
|
137
|
1 |
|
@get('/documents') |
|
138
|
|
|
def get_documents(): |
|
139
|
1 |
|
"""Read the tree's documents.""" |
|
140
|
|
|
prefixes = [str(document.prefix) for document in tree] |
|
141
|
|
|
if utilities.json_response(request): |
|
142
|
1 |
|
data = {'prefixes': prefixes} |
|
143
|
|
|
return data |
|
144
|
|
|
else: |
|
145
|
1 |
|
return template('document_list', prefixes=prefixes) |
|
146
|
1 |
|
|
|
147
|
1 |
|
|
|
148
|
1 |
|
@get('/documents/all') |
|
149
|
|
|
def get_all_documents(): |
|
150
|
1 |
|
"""Read the tree's documents.""" |
|
151
|
|
|
if utilities.json_response(request): |
|
152
|
|
|
data = {str(d.prefix): {str(i.uid): i.data for i in d} for d in tree} |
|
153
|
1 |
|
return data |
|
154
|
|
|
else: |
|
155
|
|
|
prefixes = [str(document.prefix) for document in tree] |
|
156
|
1 |
|
return template('document_list', prefixes=prefixes) |
|
157
|
1 |
|
|
|
158
|
1 |
|
|
|
159
|
1 |
|
@get('/documents/<prefix>') |
|
160
|
1 |
|
def get_document(prefix): |
|
161
|
1 |
|
"""Read a tree's document.""" |
|
162
|
|
|
document = tree.find_document(prefix) |
|
163
|
1 |
|
if utilities.json_response(request): |
|
164
|
|
|
data = {str(item.uid): item.data for item in document} |
|
165
|
|
|
return data |
|
166
|
1 |
|
else: |
|
167
|
|
|
return publisher.publish_lines(document, ext='.html', linkify=True) |
|
168
|
|
|
|
|
169
|
1 |
|
|
|
170
|
1 |
|
@get('/documents/<prefix>/items') |
|
171
|
1 |
|
def get_items(prefix): |
|
172
|
1 |
|
"""Read a document's items.""" |
|
173
|
1 |
|
document = tree.find_document(prefix) |
|
174
|
1 |
|
uids = [str(item.uid) for item in document] |
|
175
|
|
|
if utilities.json_response(request): |
|
176
|
1 |
|
data = {'uids': uids} |
|
177
|
1 |
|
return data |
|
178
|
1 |
|
else: |
|
179
|
1 |
|
return template('item_list', prefix=prefix, items=uids) |
|
180
|
1 |
|
|
|
181
|
1 |
|
|
|
182
|
|
|
@get('/documents/<prefix>/items/<uid>') |
|
183
|
|
|
def get_item(prefix, uid): |
|
184
|
1 |
|
"""Read a document's item.""" |
|
185
|
|
|
document = tree.find_document(prefix) |
|
186
|
|
|
item = document.find_item(uid) |
|
187
|
1 |
|
if utilities.json_response(request): |
|
188
|
1 |
|
return {'data': item.data} |
|
189
|
1 |
|
else: |
|
190
|
1 |
|
return publisher.publish_lines(item, ext='.html') |
|
191
|
1 |
|
|
|
192
|
1 |
|
|
|
193
|
|
|
@get('/documents/<prefix>/items/<uid>/attrs') |
|
194
|
1 |
|
def get_attrs(prefix, uid): |
|
195
|
|
|
"""Read an item's attributes.""" |
|
196
|
|
|
document = tree.find_document(prefix) |
|
197
|
|
|
item = document.find_item(uid) |
|
198
|
|
|
attrs = sorted(item.data.keys()) |
|
199
|
|
|
if utilities.json_response(request): |
|
200
|
|
|
data = {'attrs': attrs} |
|
201
|
|
|
return data |
|
202
|
|
|
else: |
|
203
|
|
|
return '<br>'.join(attrs) |
|
204
|
|
|
|
|
205
|
|
|
|
|
206
|
|
|
@get('/documents/<prefix>/items/<uid>/attrs/<name>') |
|
207
|
|
|
def get_attr(prefix, uid, name): |
|
208
|
|
|
"""Read an item's attribute value.""" |
|
209
|
|
|
document = tree.find_document(prefix) |
|
210
|
|
|
item = document.find_item(uid) |
|
211
|
|
|
value = item.data.get(name, None) |
|
212
|
|
|
if utilities.json_response(request): |
|
213
|
|
|
data = {'value': value} |
|
214
|
|
|
return data |
|
215
|
|
|
else: |
|
216
|
|
|
if isinstance(value, str): |
|
217
|
|
|
return value |
|
218
|
|
|
try: |
|
219
|
|
|
return '<br>'.join(str(e) for e in value) |
|
220
|
|
|
except TypeError: |
|
221
|
|
|
return str(value) |
|
222
|
|
|
|
|
223
|
|
|
|
|
224
|
|
|
@get('/assets/doorstop/<filename>') |
|
225
|
|
|
def get_assets(filename): |
|
226
|
|
|
"""Serve static files. Mainly used to serve CSS files and javascript.""" |
|
227
|
|
|
public_dir = os.path.join( |
|
228
|
|
|
os.path.dirname(__file__), '..', 'core', 'files', 'assets', 'doorstop' |
|
229
|
|
|
) |
|
230
|
|
|
return bottle.static_file(filename, root=public_dir) |
|
231
|
|
|
|
|
232
|
|
|
|
|
233
|
|
|
@post('/documents/<prefix>/numbers') |
|
234
|
|
|
def post_numbers(prefix): |
|
235
|
|
|
"""Create the next number in a document.""" |
|
236
|
|
|
document = tree.find_document(prefix) |
|
237
|
|
|
number = max(document.next_number, numbers[prefix]) |
|
|
|
|
|
|
238
|
|
|
numbers[prefix] = number + 1 |
|
239
|
|
|
if utilities.json_response(request): |
|
240
|
|
|
data = {'next': number} |
|
241
|
|
|
return data |
|
242
|
|
|
else: |
|
243
|
|
|
return str(number) |
|
244
|
|
|
|
|
245
|
|
|
|
|
246
|
|
|
if __name__ == '__main__': |
|
247
|
|
|
main() |
|
248
|
|
|
|