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
|
|
|
|