dapytains 0.0.1a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dapitains/__init__.py +0 -0
- dapitains/app/__init__.py +0 -0
- dapitains/app/app.py +285 -0
- dapitains/app/database.py +152 -0
- dapitains/app/ingest.py +46 -0
- dapitains/app/navigation.py +135 -0
- dapitains/constants.py +36 -0
- dapitains/errors.py +5 -0
- dapitains/metadata/__init__.py +0 -0
- dapitains/metadata/classes.py +57 -0
- dapitains/metadata/xml_parser.py +98 -0
- dapitains/tei/__init__.py +0 -0
- dapitains/tei/citeStructure.py +329 -0
- dapitains/tei/document.py +481 -0
- dapytains-0.0.1a0.dist-info/METADATA +80 -0
- dapytains-0.0.1a0.dist-info/RECORD +18 -0
- dapytains-0.0.1a0.dist-info/WHEEL +5 -0
- dapytains-0.0.1a0.dist-info/top_level.txt +1 -0
dapitains/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
dapitains/app/app.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import uritemplate
|
|
5
|
+
from flask import Flask, request, Response
|
|
6
|
+
from flask_sqlalchemy import SQLAlchemy
|
|
7
|
+
import click
|
|
8
|
+
except ImportError:
|
|
9
|
+
print("This part of the package can only be imported with the web requirements.")
|
|
10
|
+
raise
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import lxml.etree as ET
|
|
14
|
+
from dapitains.tei.document import Document
|
|
15
|
+
from dapitains.errors import InvalidRangeOrder
|
|
16
|
+
from dapitains.app.database import db, Collection, Navigation
|
|
17
|
+
from dapitains.app.navigation import get_nav, get_member_by_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def inject_json(collection: Collection, templates) -> Dict:
|
|
21
|
+
if collection.resource:
|
|
22
|
+
inj = {
|
|
23
|
+
"collection": templates["collection"].partial({"id": collection.identifier}).uri,
|
|
24
|
+
"document": templates["document"].partial({"resource": collection.identifier}).uri,
|
|
25
|
+
}
|
|
26
|
+
if collection.citeStructure:
|
|
27
|
+
inj["navigation"] = templates["navigation"].partial({"resource": collection.identifier}).uri
|
|
28
|
+
else:
|
|
29
|
+
inj = {"collection": templates["collection"].partial({"id": collection.identifier}).uri}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
**inj,
|
|
33
|
+
"totalParents": collection.total_parents,
|
|
34
|
+
"totalChildren": collection.total_children
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def msg_4xx(string, code=404) -> Response:
|
|
39
|
+
return Response(json.dumps({"message": string}), status=code, mimetype="application/json")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def collection_view(
|
|
43
|
+
identifier: Optional[str],
|
|
44
|
+
nav: str,
|
|
45
|
+
templates: Dict[str, uritemplate.URITemplate]
|
|
46
|
+
) -> Response:
|
|
47
|
+
""" Builds a collection view, regardless of how the parameters are received
|
|
48
|
+
|
|
49
|
+
:param identifier:
|
|
50
|
+
:param nav:
|
|
51
|
+
:param templates:
|
|
52
|
+
"""
|
|
53
|
+
if not identifier:
|
|
54
|
+
coll: Collection = db.session.query(Collection).filter(~Collection.parents.any()).first()
|
|
55
|
+
else:
|
|
56
|
+
coll = Collection.query.where(Collection.identifier==identifier).first()
|
|
57
|
+
if coll is None:
|
|
58
|
+
return msg_4xx("Unknown collection")
|
|
59
|
+
out = coll.json()
|
|
60
|
+
|
|
61
|
+
if nav == 'children':
|
|
62
|
+
members = db.session.query(Collection).filter(
|
|
63
|
+
Collection.parents.any(id=coll.id)
|
|
64
|
+
).all()
|
|
65
|
+
elif nav == 'parents':
|
|
66
|
+
members = db.session.query(Collection).filter(
|
|
67
|
+
Collection.children.any(id=coll.id)
|
|
68
|
+
).all()
|
|
69
|
+
else:
|
|
70
|
+
return msg_4xx(f"nav parameter has a wrong value {nav}", code=400)
|
|
71
|
+
|
|
72
|
+
return Response(json.dumps({
|
|
73
|
+
"@context": "https://distributed-text-services.github.io/specifications/context/1-alpha1.json",
|
|
74
|
+
"dtsVersion": "1-alpha",
|
|
75
|
+
**out,
|
|
76
|
+
"member": [
|
|
77
|
+
member.json(inject=inject_json(member, templates=templates))
|
|
78
|
+
for member in members
|
|
79
|
+
],
|
|
80
|
+
**inject_json(coll, templates=templates)
|
|
81
|
+
}, ), mimetype="application/ld+json", status=200)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def document_view(resource, ref, start, end, tree) -> Response:
|
|
85
|
+
if not resource:
|
|
86
|
+
return msg_4xx("Resource parameter was not provided")
|
|
87
|
+
|
|
88
|
+
collection: Collection = Collection.query.where(Collection.identifier == resource).first()
|
|
89
|
+
if not collection:
|
|
90
|
+
return msg_4xx(f"Unknown resource `{resource}`")
|
|
91
|
+
|
|
92
|
+
nav: Navigation = Navigation.query.where(Navigation.collection_id == collection.id).first()
|
|
93
|
+
if nav is None:
|
|
94
|
+
return msg_4xx(f"The resource `{resource}` does not support navigation")
|
|
95
|
+
|
|
96
|
+
tree = tree or collection.default_tree
|
|
97
|
+
|
|
98
|
+
# Check for forbidden combinations
|
|
99
|
+
if ref or start or end:
|
|
100
|
+
if tree not in nav.references:
|
|
101
|
+
return msg_4xx(f"Unknown tree {tree} for resource `{resource}`")
|
|
102
|
+
elif ref and (start or end):
|
|
103
|
+
return msg_4xx(f"You cannot provide a ref parameter as well as start or end", code=400)
|
|
104
|
+
elif not ref and ((start and not end) or (end and not start)):
|
|
105
|
+
return msg_4xx(f"Range is missing one of its parameters (start or end)", code=400)
|
|
106
|
+
|
|
107
|
+
paths = nav.paths[tree]
|
|
108
|
+
if start and end and (start not in paths or end not in paths):
|
|
109
|
+
return msg_4xx(f"Unknown reference {start} or {end} in the requested tree.", code=404)
|
|
110
|
+
if ref and ref not in paths:
|
|
111
|
+
return msg_4xx(f"Unknown reference {ref} in the requested tree.", code=404)
|
|
112
|
+
|
|
113
|
+
if not ref and not start:
|
|
114
|
+
with open(collection.filepath) as f:
|
|
115
|
+
content = f.read()
|
|
116
|
+
return Response(content, mimetype="application/xml")
|
|
117
|
+
|
|
118
|
+
doc = Document(collection.filepath)
|
|
119
|
+
return Response(
|
|
120
|
+
ET.tostring(doc.get_passage(
|
|
121
|
+
ref_or_start=ref or start,
|
|
122
|
+
end=end,
|
|
123
|
+
tree=tree
|
|
124
|
+
), encoding=str),
|
|
125
|
+
mimetype="application/xml"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def navigation_view(resource, ref, start, end, tree, down, templates: Dict[str, uritemplate.URITemplate]) -> Response:
|
|
130
|
+
if not resource:
|
|
131
|
+
return msg_4xx("Resource parameter was not provided")
|
|
132
|
+
|
|
133
|
+
collection: Collection = Collection.query.where(Collection.identifier == resource).first()
|
|
134
|
+
if not collection:
|
|
135
|
+
return msg_4xx(f"Unknown resource `{resource}`")
|
|
136
|
+
|
|
137
|
+
nav: Navigation = Navigation.query.where(Navigation.collection_id == collection.id).first()
|
|
138
|
+
if nav is None:
|
|
139
|
+
return msg_4xx(f"The resource `{resource}` does not support navigation")
|
|
140
|
+
|
|
141
|
+
tree = tree or collection.default_tree
|
|
142
|
+
|
|
143
|
+
# Check for forbidden combinations
|
|
144
|
+
if ref or start or end:
|
|
145
|
+
if tree not in nav.references:
|
|
146
|
+
return msg_4xx(f"Unknown tree {tree} for resource `{resource}`")
|
|
147
|
+
elif ref and (start or end):
|
|
148
|
+
return msg_4xx(f"You cannot provide a ref parameter as well as start or end", code=400)
|
|
149
|
+
elif not ref and ((start and not end) or (end and not start)):
|
|
150
|
+
return msg_4xx(f"Range is missing one of its parameters (start or end)", code=400)
|
|
151
|
+
|
|
152
|
+
# Start the response
|
|
153
|
+
out = {
|
|
154
|
+
"@context": "https://distributed-text-services.github.io/specifications/context/1-alpha1.json",
|
|
155
|
+
"dtsVersion": "1-alpha",
|
|
156
|
+
"@type": "Navigation",
|
|
157
|
+
"@id": templates["navigation"].expand({
|
|
158
|
+
"ref": ref, "down": down, "start": start, "end": end, "tree": tree
|
|
159
|
+
}),
|
|
160
|
+
"resource": collection.json(inject={k: v.uri for k, v in templates.items()}),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
refs = nav.references[tree]
|
|
164
|
+
paths = nav.paths[tree]
|
|
165
|
+
|
|
166
|
+
# Three first rows of the specs for combination of down/ref/start/end
|
|
167
|
+
if down is None:
|
|
168
|
+
if ref:
|
|
169
|
+
out["ref"] = {"@type": "CitableUnit", **get_member_by_path(refs, paths[ref])}
|
|
170
|
+
elif start and end:
|
|
171
|
+
out["start"] = {"@type": "CitableUnit", **get_member_by_path(refs, paths[start])}
|
|
172
|
+
out["end"] = {"@type": "CitableUnit", **get_member_by_path(refs, paths[end])}
|
|
173
|
+
else:
|
|
174
|
+
return msg_4xx(f"The down query parameter is required when requesting without ref or start/end", code=400)
|
|
175
|
+
return Response(json.dumps(out), mimetype="application/json", status=200)
|
|
176
|
+
elif down == 0 and start and end:
|
|
177
|
+
return msg_4xx(f"The down query parameter cannot be `0` while using start/end", code=400)
|
|
178
|
+
elif down == 0 and not ref:
|
|
179
|
+
return msg_4xx(f"The down query parameter cannot be `0` without using the `ref` parameter", code=400)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
members, start, end = get_nav(refs=refs, paths=paths, start_or_ref=start or ref, end=end, down=down)
|
|
183
|
+
except InvalidRangeOrder:
|
|
184
|
+
return msg_4xx("End reference comes before start in the document order. Interchange start and end.", code=400)
|
|
185
|
+
except Exception:
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
out["member"] = members
|
|
189
|
+
if end:
|
|
190
|
+
out["start"] = start
|
|
191
|
+
out["end"] = end
|
|
192
|
+
elif start:
|
|
193
|
+
out["ref"] = start
|
|
194
|
+
|
|
195
|
+
return Response(json.dumps(out), mimetype="application/ld+json", status=200)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def create_app(
|
|
199
|
+
app: Flask,
|
|
200
|
+
base_uri: str,
|
|
201
|
+
use_query: bool = False
|
|
202
|
+
) -> (Flask, SQLAlchemy):
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
Initialisation of the DB is up to you
|
|
206
|
+
"""
|
|
207
|
+
navigation_template = uritemplate.URITemplate(base_uri+"/navigation/{?resource}{&ref,start,end,tree,down}")
|
|
208
|
+
collection_template = uritemplate.URITemplate(base_uri+"/collection/{?id}{&nav}")
|
|
209
|
+
document_template = uritemplate.URITemplate(base_uri+"/document/{?resource}{&ref,start,end,tree}")
|
|
210
|
+
|
|
211
|
+
@app.route("/")
|
|
212
|
+
def index_route():
|
|
213
|
+
return Response(
|
|
214
|
+
json.dumps({
|
|
215
|
+
"@context": "https://distributed-text-services.github.io/specifications/context/1-alpha1.json",
|
|
216
|
+
"dtsVersion": "1-alpha",
|
|
217
|
+
"@id": f"{request.url_root}{request.path}",
|
|
218
|
+
"@type": "EntryPoint",
|
|
219
|
+
"collection": collection_template.uri,
|
|
220
|
+
"navigation": navigation_template.uri,
|
|
221
|
+
"document": document_template.uri
|
|
222
|
+
}),
|
|
223
|
+
mimetype="application/ld+json"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
@app.route("/collection/")
|
|
227
|
+
def collection_route():
|
|
228
|
+
resource = request.args.get("id")
|
|
229
|
+
nav = request.args.get("nav", "children")
|
|
230
|
+
|
|
231
|
+
return collection_view(resource, nav, templates={
|
|
232
|
+
"navigation": navigation_template,
|
|
233
|
+
"collection": collection_template,
|
|
234
|
+
"document": document_template,
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
@app.route("/navigation/")
|
|
238
|
+
def navigation_route():
|
|
239
|
+
resource = request.args.get("resource")
|
|
240
|
+
ref = request.args.get("ref")
|
|
241
|
+
start = request.args.get("start")
|
|
242
|
+
end = request.args.get("end")
|
|
243
|
+
tree = request.args.get("tree")
|
|
244
|
+
down = request.args.get("down", type=int, default=None)
|
|
245
|
+
|
|
246
|
+
return navigation_view(resource, ref, start, end, tree, down, templates={
|
|
247
|
+
"navigation": navigation_template.partial({"resource": resource}),
|
|
248
|
+
"collection": collection_template.partial({"id": resource}),
|
|
249
|
+
"document": document_template.partial({"resource": resource}),
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
@app.route("/document/")
|
|
253
|
+
def document_route():
|
|
254
|
+
resource = request.args.get("resource")
|
|
255
|
+
ref = request.args.get("ref")
|
|
256
|
+
start = request.args.get("start")
|
|
257
|
+
end = request.args.get("end")
|
|
258
|
+
tree = request.args.get("tree")
|
|
259
|
+
return document_view(resource, ref, start, end, tree)
|
|
260
|
+
|
|
261
|
+
return app, db
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
import os
|
|
266
|
+
from dapitains.app.ingest import store_catalog
|
|
267
|
+
from dapitains.metadata.xml_parser import parse
|
|
268
|
+
|
|
269
|
+
app = Flask(__name__)
|
|
270
|
+
_, db = create_app(app, base_uri="http://localhost:5000")
|
|
271
|
+
|
|
272
|
+
basedir = os.path.abspath(os.path.dirname(__file__))
|
|
273
|
+
db_path = os.path.join(basedir, 'app.db')
|
|
274
|
+
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
|
|
275
|
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
276
|
+
|
|
277
|
+
db.init_app(app)
|
|
278
|
+
with app.app_context():
|
|
279
|
+
db.drop_all()
|
|
280
|
+
db.create_all()
|
|
281
|
+
|
|
282
|
+
catalog, _ = parse(f"{basedir}/../../tests/catalog/example-collection.xml")
|
|
283
|
+
store_catalog(catalog)
|
|
284
|
+
|
|
285
|
+
app.run()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from flask_sqlalchemy import SQLAlchemy
|
|
5
|
+
from sqlalchemy.ext.mutable import MutableDict, Mutable
|
|
6
|
+
from sqlalchemy.types import TypeDecorator, TEXT
|
|
7
|
+
from sqlalchemy import func
|
|
8
|
+
import click
|
|
9
|
+
except ImportError:
|
|
10
|
+
print("This part of the package can only be imported with the web requirements.")
|
|
11
|
+
raise
|
|
12
|
+
|
|
13
|
+
from typing import Optional, Dict, Any
|
|
14
|
+
import dapitains.metadata.classes as abstracts
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CustomKeyJSONDecoder(json.JSONDecoder):
|
|
19
|
+
def __init__(self, *args, **kwargs):
|
|
20
|
+
super().__init__(object_hook=self.object_hook, *args, **kwargs)
|
|
21
|
+
|
|
22
|
+
def object_hook(self, obj):
|
|
23
|
+
# Only convert 'None' string keys back to None
|
|
24
|
+
return {None if k == 'null' else k: v for k, v in obj.items()}
|
|
25
|
+
|
|
26
|
+
db = SQLAlchemy()
|
|
27
|
+
|
|
28
|
+
parent_child_association = db.Table('parent_child_association',
|
|
29
|
+
db.Column('parent_id', db.Integer, db.ForeignKey('collections.id'), primary_key=True),
|
|
30
|
+
db.Column('child_id', db.Integer, db.ForeignKey('collections.id'), primary_key=True)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class JSONEncoded(TypeDecorator):
|
|
35
|
+
"""Enables JSON storage by encoding and decoding on the fly."""
|
|
36
|
+
impl = TEXT
|
|
37
|
+
|
|
38
|
+
def process_bind_param(self, value, dialect):
|
|
39
|
+
if value is None:
|
|
40
|
+
return None
|
|
41
|
+
else:
|
|
42
|
+
return json.dumps(value)
|
|
43
|
+
|
|
44
|
+
def process_result_value(self, value, dialect):
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
return json.loads(value, cls=CustomKeyJSONDecoder)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Collection(db.Model):
|
|
51
|
+
__tablename__ = 'collections'
|
|
52
|
+
|
|
53
|
+
id = db.Column(db.Integer, primary_key=True, autoincrement=True, nullable=False)
|
|
54
|
+
identifier = db.Column(db.String, nullable=False, unique=True)
|
|
55
|
+
title = db.Column(db.String, nullable=False)
|
|
56
|
+
description = db.Column(db.String, nullable=True)
|
|
57
|
+
resource = db.Column(db.Boolean, default=False)
|
|
58
|
+
filepath = db.Column(db.String, nullable=True)
|
|
59
|
+
dublin_core = db.Column(JSONEncoded, nullable=True)
|
|
60
|
+
extensions = db.Column(JSONEncoded, nullable=True)
|
|
61
|
+
citeStructure = db.Column(JSONEncoded, nullable=True)
|
|
62
|
+
default_tree = db.Column(db.String, nullable=True)
|
|
63
|
+
|
|
64
|
+
# One-to-one relationship with Navigation
|
|
65
|
+
navigation = db.relationship('Navigation', uselist=False, backref='collection', lazy=True)
|
|
66
|
+
|
|
67
|
+
parents = db.relationship(
|
|
68
|
+
'Collection',
|
|
69
|
+
secondary=parent_child_association,
|
|
70
|
+
primaryjoin=id == parent_child_association.c.child_id,
|
|
71
|
+
secondaryjoin=id == parent_child_association.c.parent_id,
|
|
72
|
+
backref='children'
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def total_children(self):
|
|
77
|
+
return db.session.query(func.count(parent_child_association.c.child_id)).filter(
|
|
78
|
+
parent_child_association.c.parent_id == self.id
|
|
79
|
+
).scalar()
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def total_parents(self):
|
|
83
|
+
return db.session.query(func.count(parent_child_association.c.parent_id)).filter(
|
|
84
|
+
parent_child_association.c.child_id == self.id
|
|
85
|
+
).scalar()
|
|
86
|
+
|
|
87
|
+
def json(self, inject: Optional[Dict[str, Any]] = None):
|
|
88
|
+
data = {
|
|
89
|
+
"@type": "Resource" if self.resource else "Collection",
|
|
90
|
+
"@id": self.identifier,
|
|
91
|
+
"title": self.title,
|
|
92
|
+
**(inject or {})
|
|
93
|
+
}
|
|
94
|
+
if self.description:
|
|
95
|
+
data["description"] = self.description
|
|
96
|
+
if self.resource:
|
|
97
|
+
data["citationTrees"] = []
|
|
98
|
+
if self.citeStructure:
|
|
99
|
+
data["citationTrees"] = [self.citeStructure[self.default_tree]]
|
|
100
|
+
if len(self.citeStructure) >= 1:
|
|
101
|
+
data["citationTrees"][0]["identifier"] = self.default_tree
|
|
102
|
+
for key in self.citeStructure:
|
|
103
|
+
if key != self.default_tree:
|
|
104
|
+
data["citationTrees"].append(self.citeStructure[key])
|
|
105
|
+
self.citeStructure[key]["identifier"] = key
|
|
106
|
+
for tree in data["citationTrees"]:
|
|
107
|
+
tree["@type"] = "CitationTree"
|
|
108
|
+
if self.dublin_core: # ToDo: Fix the way it's presented to adapt to dts view
|
|
109
|
+
data["dublinCore"] = self.dublin_core
|
|
110
|
+
if self.extensions:
|
|
111
|
+
data["extensions"] = self.extensions
|
|
112
|
+
|
|
113
|
+
return data
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_class(cls, obj: abstracts.Collection) -> "Collection":
|
|
117
|
+
dublin_core = defaultdict(list)
|
|
118
|
+
for dublin in obj.dublin_core:
|
|
119
|
+
if dublin.language:
|
|
120
|
+
dublin_core[dublin.term].append({"lang": dublin.language, "value": dublin.value})
|
|
121
|
+
else:
|
|
122
|
+
dublin_core[dublin.term].append(dublin.value)
|
|
123
|
+
|
|
124
|
+
extensions = defaultdict(list)
|
|
125
|
+
for exte in obj.extensions:
|
|
126
|
+
if exte.language:
|
|
127
|
+
extensions[exte.term].append({"lang": exte.language, "value": exte.value})
|
|
128
|
+
else:
|
|
129
|
+
extensions[exte.term].append(exte.value)
|
|
130
|
+
|
|
131
|
+
obj = cls(
|
|
132
|
+
identifier=obj.identifier,
|
|
133
|
+
title=obj.title,
|
|
134
|
+
description=obj.description,
|
|
135
|
+
resource=obj.resource,
|
|
136
|
+
filepath=obj.filepath,
|
|
137
|
+
# We are dumping because it's not read or accessible
|
|
138
|
+
dublin_core=dublin_core, #[dub.json() for dub in obj.dublin_core],
|
|
139
|
+
extensions=extensions, # [ext.json() for ext in obj.extension]
|
|
140
|
+
)
|
|
141
|
+
return obj
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Navigation(db.Model):
|
|
145
|
+
__tablename__ = 'navigations'
|
|
146
|
+
|
|
147
|
+
id = db.Column(db.Integer, primary_key=True, autoincrement=True, nullable=False)
|
|
148
|
+
collection_id = db.Column(db.Integer, db.ForeignKey('collections.id'), nullable=False, unique=True)
|
|
149
|
+
|
|
150
|
+
# JSON fields stored as TEXT
|
|
151
|
+
paths = db.Column(JSONEncoded, nullable=False, default={})
|
|
152
|
+
references = db.Column(JSONEncoded, nullable=False, default={})
|
dapitains/app/ingest.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import Dict, Optional
|
|
2
|
+
from dapitains.app.database import Collection, Navigation, db, parent_child_association
|
|
3
|
+
from dapitains.app.navigation import generate_paths
|
|
4
|
+
from dapitains.metadata.xml_parser import Catalog
|
|
5
|
+
from dapitains.tei.document import Document
|
|
6
|
+
import tqdm
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def store_single(catalog: Catalog, keys: Optional[Dict[str, int]]):
|
|
10
|
+
keys = keys or {}
|
|
11
|
+
for identifier, collection in tqdm.tqdm(catalog.objects.items(), desc="Parsing all collections"):
|
|
12
|
+
coll_db = Collection.from_class(collection)
|
|
13
|
+
db.session.add(coll_db)
|
|
14
|
+
db.session.flush()
|
|
15
|
+
keys[coll_db.identifier] = coll_db.id
|
|
16
|
+
if collection.resource:
|
|
17
|
+
doc = Document(collection.filepath)
|
|
18
|
+
if doc.citeStructure:
|
|
19
|
+
references = {
|
|
20
|
+
tree: [ref.json() for ref in obj.find_refs(doc.xml, structure=obj.structure)]
|
|
21
|
+
for tree, obj in doc.citeStructure.items()
|
|
22
|
+
}
|
|
23
|
+
paths = {key: generate_paths(tree) for key, tree in references.items()}
|
|
24
|
+
nav = Navigation(collection_id=coll_db.id, paths=paths, references=references)
|
|
25
|
+
db.session.add(nav)
|
|
26
|
+
coll_db.citeStructure = {
|
|
27
|
+
key: value.structure.json()
|
|
28
|
+
for key, value in doc.citeStructure.items()
|
|
29
|
+
}
|
|
30
|
+
coll_db.default_tree = doc.default_tree
|
|
31
|
+
db.session.add(coll_db)
|
|
32
|
+
db.session.commit()
|
|
33
|
+
|
|
34
|
+
for parent, child in catalog.relationships:
|
|
35
|
+
insert_statement = parent_child_association.insert().values(
|
|
36
|
+
parent_id=keys[parent],
|
|
37
|
+
child_id=keys[child]
|
|
38
|
+
)
|
|
39
|
+
db.session.execute(insert_statement)
|
|
40
|
+
db.session.commit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def store_catalog(*catalogs):
|
|
44
|
+
keys = {}
|
|
45
|
+
for catalog in catalogs:
|
|
46
|
+
store_single(catalog, keys)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from typing import List, Dict, Any, Optional, Tuple
|
|
2
|
+
from dapitains.errors import InvalidRangeOrder
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_member_by_path(data: List[Dict[str, Any]], path: List[int]) -> Optional[Dict[str, Any]]:
|
|
6
|
+
"""
|
|
7
|
+
Retrieve the member at the specified path in the nested data structure.
|
|
8
|
+
|
|
9
|
+
:param data: The nested data structure (list of dictionaries).
|
|
10
|
+
:param path: A list of indices that represent the path to the desired member.
|
|
11
|
+
:return: The member at the specified path, or None if the path is invalid.
|
|
12
|
+
"""
|
|
13
|
+
current_level = data
|
|
14
|
+
|
|
15
|
+
path_copy = [] + path
|
|
16
|
+
while path_copy:
|
|
17
|
+
index = path_copy.pop(0)
|
|
18
|
+
try:
|
|
19
|
+
current_level = current_level[index]
|
|
20
|
+
if 'members' in current_level and path_copy:
|
|
21
|
+
current_level = current_level['members']
|
|
22
|
+
except (IndexError, KeyError):
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
return current_level
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def strip_members(obj: Dict[str, Any], add_type: bool = False) -> Dict[str, Any]:
|
|
29
|
+
obj = {k: v for k, v in obj.items() if k != "members"}
|
|
30
|
+
if add_type:
|
|
31
|
+
obj["@type"] = "CitableUnit"
|
|
32
|
+
return obj
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_paths(data: List[Dict[str, Any]], path: Optional[List[int]] = None) -> Dict[str, List[int]]:
|
|
36
|
+
"""
|
|
37
|
+
Generate a dictionary mapping each 'ref' in a nested data structure to its path.
|
|
38
|
+
|
|
39
|
+
The path is represented as a list of indices that show how to access each 'ref'
|
|
40
|
+
in the nested structure.
|
|
41
|
+
|
|
42
|
+
:param data: The nested data structure (list of dictionaries). Each dictionary
|
|
43
|
+
can have a 'ref' and/or 'members' key.
|
|
44
|
+
:param path: A list of indices representing the current path in the nested data
|
|
45
|
+
structure. Used internally for recursion. Defaults to None for the
|
|
46
|
+
initial call.
|
|
47
|
+
:return: A dictionary where each key is a 'ref' and each value is a list of indices
|
|
48
|
+
representing the path to that 'ref' in the nested structure.
|
|
49
|
+
"""
|
|
50
|
+
if path is None:
|
|
51
|
+
path = []
|
|
52
|
+
|
|
53
|
+
paths = {}
|
|
54
|
+
|
|
55
|
+
def recurse(items, current_path):
|
|
56
|
+
for index, item in enumerate(items):
|
|
57
|
+
ref = item.get('identifier')
|
|
58
|
+
if ref:
|
|
59
|
+
# Record the path for the current reference
|
|
60
|
+
paths[ref] = current_path + [index]
|
|
61
|
+
|
|
62
|
+
members = item.get('members')
|
|
63
|
+
if members:
|
|
64
|
+
# Recurse into the 'members' list
|
|
65
|
+
recurse(members, current_path + [index])
|
|
66
|
+
|
|
67
|
+
recurse(data, [])
|
|
68
|
+
return paths
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_nav(
|
|
72
|
+
refs: List[Dict[str, Any]],
|
|
73
|
+
paths: Dict[str, List[int]],
|
|
74
|
+
start_or_ref: Optional[str] = None,
|
|
75
|
+
end: Optional[str] = None,
|
|
76
|
+
down: Optional[int] = 1
|
|
77
|
+
) -> Tuple[List[Dict[str, Any]], Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
|
78
|
+
""" Given a references set and a path set, provide the CitableUnit from start to end at down level.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
paths_index = list(paths.keys())
|
|
83
|
+
start_index, end_index = None, len(paths_index)
|
|
84
|
+
|
|
85
|
+
if end:
|
|
86
|
+
# For end, as end is inclusive, we check for the last partial match
|
|
87
|
+
# (ie, if Mark is [1], we want everything starting
|
|
88
|
+
# by [1].)
|
|
89
|
+
end_index = paths_index.index(end)
|
|
90
|
+
len_end = len(paths[end])
|
|
91
|
+
for idx, reference in enumerate(paths_index[end_index+1:]):
|
|
92
|
+
if paths[reference][:len_end] == paths[end]:
|
|
93
|
+
end_index = end_index+idx
|
|
94
|
+
else:
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
if start_or_ref:
|
|
98
|
+
start_index = paths_index.index(start_or_ref)
|
|
99
|
+
if not end:
|
|
100
|
+
if down == 0:
|
|
101
|
+
end_index = len(paths_index)
|
|
102
|
+
else:
|
|
103
|
+
for index, reference in enumerate(paths_index[start_index+1:]):
|
|
104
|
+
if len(paths[start_or_ref]) == len(paths[reference]):
|
|
105
|
+
end_index = index + start_index
|
|
106
|
+
if start_index > end_index:
|
|
107
|
+
raise InvalidRangeOrder
|
|
108
|
+
|
|
109
|
+
paths = dict(list(paths.items())[start_index:end_index+1])
|
|
110
|
+
|
|
111
|
+
current_level = []
|
|
112
|
+
start_path, end_path = None, None
|
|
113
|
+
if start_or_ref:
|
|
114
|
+
start_path = paths[start_or_ref]
|
|
115
|
+
current_level.append(len(start_path))
|
|
116
|
+
if end:
|
|
117
|
+
end_path = paths[end]
|
|
118
|
+
current_level.append(len(end_path))
|
|
119
|
+
|
|
120
|
+
current_level = max(current_level) if current_level else 0
|
|
121
|
+
|
|
122
|
+
if down == 0:
|
|
123
|
+
paths = {key: value for key, value in paths.items() if len(value) == current_level}
|
|
124
|
+
elif down == -1:
|
|
125
|
+
paths = {key: value for key, value in paths.items() if current_level <= len(value)}
|
|
126
|
+
else:
|
|
127
|
+
paths = {key: value for key, value in paths.items() if current_level <= len(value) <= down + current_level}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
[
|
|
131
|
+
strip_members(get_member_by_path(refs, path), add_type=True) for path in paths.values()
|
|
132
|
+
],
|
|
133
|
+
strip_members(get_member_by_path(refs, start_path), add_type=True) if start_path else None,
|
|
134
|
+
strip_members(get_member_by_path(refs, end_path), add_type=True) if end_path else None
|
|
135
|
+
)
|
dapitains/constants.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
saxon_version = os.getenv("pysaxon", "HE")
|
|
6
|
+
saxon_license = os.getenv("pysaxon_license", "")
|
|
7
|
+
logging.info(f"Using SaxonLib {saxon_version}")
|
|
8
|
+
if saxon_version == "HE":
|
|
9
|
+
import saxonche as saxonlib
|
|
10
|
+
PROCESSOR = saxonlib.PySaxonProcessor()
|
|
11
|
+
elif saxon_version == "PE":
|
|
12
|
+
import saxoncpe as saxonlib
|
|
13
|
+
PROCESSOR = saxonlib.PySaxonProcessor(license=saxon_license)
|
|
14
|
+
elif saxon_version == "PE":
|
|
15
|
+
import saxoncee as saxonlib
|
|
16
|
+
PROCESSOR = saxonlib.PySaxonProcessor(license=saxon_license)
|
|
17
|
+
except ImportError:
|
|
18
|
+
print("Unable to import the required PySaxonC version, resorting to PySaxonC-HE")
|
|
19
|
+
import saxonche as saxonlib
|
|
20
|
+
PROCESSOR = saxonlib.PySaxonProcessor()
|
|
21
|
+
|
|
22
|
+
PROCESSOR.set_configuration_property("indent-space", "0")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_xpath_proc(elem: saxonlib.PyXdmNode) -> saxonlib.PyXPathProcessor:
|
|
28
|
+
""" Builds an XPath processor around a given element, with the default TEI namespace
|
|
29
|
+
|
|
30
|
+
:param elem: An XML node, root or not
|
|
31
|
+
:return: XPathProccesor
|
|
32
|
+
"""
|
|
33
|
+
xpath = PROCESSOR.new_xpath_processor()
|
|
34
|
+
xpath.declare_namespace("", "http://www.tei-c.org/ns/1.0")
|
|
35
|
+
xpath.set_context(xdm_item=elem)
|
|
36
|
+
return xpath
|
dapitains/errors.py
ADDED
|
File without changes
|