quickdbd-cli 0.1.0__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.
- quickdbd_cli/__init__.py +1 -0
- quickdbd_cli/__main__.py +5 -0
- quickdbd_cli/assets/Inter-Bold.ttf +0 -0
- quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
- quickdbd_cli/assets/Inter-LICENSE.txt +92 -0
- quickdbd_cli/assets/Inter-Regular.ttf +0 -0
- quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
- quickdbd_cli/assets/editor.js +244 -0
- quickdbd_cli/assets/elk-LICENSE.md +263 -0
- quickdbd_cli/assets/elk.bundled.js +6605 -0
- quickdbd_cli/assets/font-metrics.json +1 -0
- quickdbd_cli/assets/render.js +230 -0
- quickdbd_cli/assets/style.css +83 -0
- quickdbd_cli/assets/viewer.js +337 -0
- quickdbd_cli/cli.py +169 -0
- quickdbd_cli/export.py +79 -0
- quickdbd_cli/html.py +97 -0
- quickdbd_cli/jsrt.py +77 -0
- quickdbd_cli/layout.py +207 -0
- quickdbd_cli/measure.py +111 -0
- quickdbd_cli/model.py +107 -0
- quickdbd_cli/parser.py +717 -0
- quickdbd_cli/project.py +69 -0
- quickdbd_cli/serve.py +210 -0
- quickdbd_cli-0.1.0.dist-info/METADATA +153 -0
- quickdbd_cli-0.1.0.dist-info/RECORD +32 -0
- quickdbd_cli-0.1.0.dist-info/WHEEL +4 -0
- quickdbd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- quickdbd_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- quickdbd_cli-0.1.0.dist-info/licenses/NOTICE +19 -0
- quickdbd_cli-0.1.0.dist-info/licenses/src/quickdbd_cli/assets/Inter-LICENSE.txt +92 -0
- quickdbd_cli-0.1.0.dist-info/licenses/src/quickdbd_cli/assets/elk-LICENSE.md +263 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/* qdbd interactive viewer: pan/zoom, search, minimap, focus, collapse, FK hover, export.
|
|
2
|
+
* In serve mode (data.serve) tables can also be dragged; positions are POSTed to the server. */
|
|
3
|
+
(function () {
|
|
4
|
+
"use strict";
|
|
5
|
+
var Q = window.qdbd;
|
|
6
|
+
var data = JSON.parse(document.getElementById("qdbd-data").textContent);
|
|
7
|
+
var S = {
|
|
8
|
+
model: data.model,
|
|
9
|
+
pos: data.positions,
|
|
10
|
+
mode: "full",
|
|
11
|
+
focus: null,
|
|
12
|
+
view: { x: 0, y: 0, k: 1 },
|
|
13
|
+
serve: !!data.serve,
|
|
14
|
+
};
|
|
15
|
+
var svg = document.getElementById("canvas");
|
|
16
|
+
var vp = document.getElementById("vp");
|
|
17
|
+
var zoomval = document.getElementById("zoomval");
|
|
18
|
+
var byId = {};
|
|
19
|
+
|
|
20
|
+
function index() {
|
|
21
|
+
byId = {};
|
|
22
|
+
S.model.tables.forEach(function (t) { byId[t.id] = t; });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* ------------------------------------------------------------- render */
|
|
26
|
+
function render() {
|
|
27
|
+
vp.innerHTML = Q.renderInner(S.model, S.pos, S.mode);
|
|
28
|
+
applyFocus();
|
|
29
|
+
drawMinimap();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderEdges() {
|
|
33
|
+
var g = vp.querySelector(".edges");
|
|
34
|
+
if (g) g.innerHTML = Q.renderEdges(S.model, S.pos, S.mode);
|
|
35
|
+
applyFocus();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function applyView() {
|
|
39
|
+
var v = S.view;
|
|
40
|
+
vp.setAttribute("transform", "translate(" + v.x + "," + v.y + ") scale(" + v.k + ")");
|
|
41
|
+
zoomval.textContent = Math.round(v.k * 100) + "%";
|
|
42
|
+
drawViewportRect();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stageSize() {
|
|
46
|
+
var r = svg.getBoundingClientRect();
|
|
47
|
+
return [r.width || 800, r.height || 600];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fit() {
|
|
51
|
+
var b = Q.bounds(S.model, S.pos, S.mode), sz = stageSize(), pad = 40;
|
|
52
|
+
var k = Math.min((sz[0] - 2 * pad) / (b[2] - b[0]), (sz[1] - 2 * pad) / (b[3] - b[1]), 1.5);
|
|
53
|
+
k = Math.max(k, 0.05);
|
|
54
|
+
S.view = { k: k, x: (sz[0] - (b[2] - b[0]) * k) / 2 - b[0] * k, y: (sz[1] - (b[3] - b[1]) * k) / 2 - b[1] * k };
|
|
55
|
+
applyView();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function zoomAt(factor, cx, cy) {
|
|
59
|
+
var v = S.view, k = Math.min(4, Math.max(0.03, v.k * factor));
|
|
60
|
+
S.view = { k: k, x: cx - (cx - v.x) * (k / v.k), y: cy - (cy - v.y) * (k / v.k) };
|
|
61
|
+
applyView();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function centerOn(id) {
|
|
65
|
+
var t = byId[id], p = S.pos[id];
|
|
66
|
+
if (!t || !p) return;
|
|
67
|
+
var g = Q.geometry(S.model, S.mode)[id], sz = stageSize();
|
|
68
|
+
var k = Math.max(S.view.k, 0.9);
|
|
69
|
+
S.view = { k: k, x: sz[0] / 2 - (p[0] + g.w / 2) * k, y: sz[1] / 2 - (p[1] + g.h / 2) * k };
|
|
70
|
+
applyView();
|
|
71
|
+
var el = vp.querySelector('.t[data-id="' + cssEsc(id) + '"]');
|
|
72
|
+
if (el) {
|
|
73
|
+
el.classList.add("flash");
|
|
74
|
+
setTimeout(function () { el.classList.remove("flash"); }, 1200);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function cssEsc(s) { return window.CSS && CSS.escape ? CSS.escape(s) : s.replace(/"/g, '\\"'); }
|
|
79
|
+
|
|
80
|
+
/* ------------------------------------------------------------- focus + hover */
|
|
81
|
+
function neighbours(id) {
|
|
82
|
+
var set = {};
|
|
83
|
+
set[id] = true;
|
|
84
|
+
S.model.rels.forEach(function (r) {
|
|
85
|
+
if (r.a.t === id) set[r.b.t] = true;
|
|
86
|
+
if (r.b.t === id) set[r.a.t] = true;
|
|
87
|
+
});
|
|
88
|
+
return set;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function applyFocus() {
|
|
92
|
+
if (!S.focus || !byId[S.focus]) {
|
|
93
|
+
S.focus = null;
|
|
94
|
+
svg.classList.remove("focus");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
var set = neighbours(S.focus);
|
|
98
|
+
svg.classList.add("focus");
|
|
99
|
+
vp.querySelectorAll(".t").forEach(function (el) { el.classList.toggle("dim", !set[el.getAttribute("data-id")]); });
|
|
100
|
+
vp.querySelectorAll(".e").forEach(function (el) {
|
|
101
|
+
var a = el.getAttribute("data-a"), b = el.getAttribute("data-b");
|
|
102
|
+
el.classList.toggle("dim", !(a === S.focus || b === S.focus));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function clearHover() {
|
|
107
|
+
vp.querySelectorAll(".hl").forEach(function (el) { el.classList.remove("hl"); });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
vp.addEventListener("mouseover", function (ev) {
|
|
111
|
+
var row = ev.target.closest && ev.target.closest(".r");
|
|
112
|
+
clearHover();
|
|
113
|
+
if (!row) return;
|
|
114
|
+
var tid = row.closest(".t").getAttribute("data-id"), cid = row.getAttribute("data-c");
|
|
115
|
+
vp.querySelectorAll(".e").forEach(function (e) {
|
|
116
|
+
var a = e.getAttribute("data-a"), ac = e.getAttribute("data-ac"), b = e.getAttribute("data-b"), bc = e.getAttribute("data-bc");
|
|
117
|
+
var other = null;
|
|
118
|
+
if (a === tid && ac === cid) other = [b, bc];
|
|
119
|
+
else if (b === tid && bc === cid) other = [a, ac];
|
|
120
|
+
if (!other) return;
|
|
121
|
+
e.classList.add("hl");
|
|
122
|
+
row.classList.add("hl");
|
|
123
|
+
var t = vp.querySelector('.t[data-id="' + cssEsc(other[0]) + '"] .r[data-c="' + cssEsc(other[1]) + '"]');
|
|
124
|
+
if (t) t.classList.add("hl");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
vp.addEventListener("mouseleave", clearHover);
|
|
128
|
+
|
|
129
|
+
/* ------------------------------------------------------------- pointer: pan, click, drag */
|
|
130
|
+
var drag = null;
|
|
131
|
+
svg.addEventListener("pointerdown", function (ev) {
|
|
132
|
+
if (ev.button !== 0) return;
|
|
133
|
+
var tEl = ev.target.closest && ev.target.closest(".t");
|
|
134
|
+
drag = { sx: ev.clientX, sy: ev.clientY, moved: false, table: tEl ? tEl.getAttribute("data-id") : null, el: tEl };
|
|
135
|
+
if (drag.table && S.serve) {
|
|
136
|
+
drag.start = S.pos[drag.table].slice();
|
|
137
|
+
} else {
|
|
138
|
+
drag.view = { x: S.view.x, y: S.view.y };
|
|
139
|
+
svg.classList.add("panning");
|
|
140
|
+
}
|
|
141
|
+
svg.setPointerCapture(ev.pointerId);
|
|
142
|
+
});
|
|
143
|
+
var raf = null;
|
|
144
|
+
svg.addEventListener("pointermove", function (ev) {
|
|
145
|
+
if (!drag) return;
|
|
146
|
+
var dx = ev.clientX - drag.sx, dy = ev.clientY - drag.sy;
|
|
147
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
|
|
148
|
+
if (!drag.moved) return;
|
|
149
|
+
if (drag.start) {
|
|
150
|
+
var p = [Math.round(drag.start[0] + dx / S.view.k), Math.round(drag.start[1] + dy / S.view.k)];
|
|
151
|
+
S.pos[drag.table] = p;
|
|
152
|
+
drag.el.setAttribute("transform", "translate(" + p[0] + "," + p[1] + ")");
|
|
153
|
+
if (!raf) raf = requestAnimationFrame(function () { raf = null; renderEdges(); });
|
|
154
|
+
} else if (drag.view) {
|
|
155
|
+
S.view.x = drag.view.x + dx;
|
|
156
|
+
S.view.y = drag.view.y + dy;
|
|
157
|
+
applyView();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
svg.addEventListener("pointerup", function () {
|
|
161
|
+
if (!drag) return;
|
|
162
|
+
svg.classList.remove("panning");
|
|
163
|
+
var d = drag;
|
|
164
|
+
drag = null;
|
|
165
|
+
if (!d.moved) {
|
|
166
|
+
S.focus = d.table && S.focus !== d.table ? d.table : null;
|
|
167
|
+
applyFocus();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (d.start) {
|
|
171
|
+
drawMinimap();
|
|
172
|
+
var name = byId[d.table].name, body = {};
|
|
173
|
+
body[name] = S.pos[d.table];
|
|
174
|
+
if (window.qdbdServe) window.qdbdServe.saveLayout(body);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
svg.addEventListener("wheel", function (ev) {
|
|
178
|
+
ev.preventDefault();
|
|
179
|
+
var r = svg.getBoundingClientRect();
|
|
180
|
+
var factor = Math.exp(-ev.deltaY * (ev.ctrlKey ? 0.01 : 0.0015));
|
|
181
|
+
zoomAt(factor, ev.clientX - r.left, ev.clientY - r.top);
|
|
182
|
+
}, { passive: false });
|
|
183
|
+
document.addEventListener("keydown", function (ev) {
|
|
184
|
+
if (ev.key === "Escape") { S.focus = null; applyFocus(); }
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/* ------------------------------------------------------------- toolbar */
|
|
188
|
+
function center() { var s = stageSize(); return [s[0] / 2, s[1] / 2]; }
|
|
189
|
+
document.getElementById("zin").onclick = function () { var c = center(); zoomAt(1.25, c[0], c[1]); };
|
|
190
|
+
document.getElementById("zout").onclick = function () { var c = center(); zoomAt(0.8, c[0], c[1]); };
|
|
191
|
+
document.getElementById("zfit").onclick = fit;
|
|
192
|
+
document.querySelectorAll("#modes button").forEach(function (b) {
|
|
193
|
+
b.onclick = function () {
|
|
194
|
+
S.mode = b.getAttribute("data-mode");
|
|
195
|
+
document.querySelectorAll("#modes button").forEach(function (o) { o.classList.toggle("on", o === b); });
|
|
196
|
+
render();
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/* ------------------------------------------------------------- search */
|
|
201
|
+
var input = document.getElementById("q"), results = document.getElementById("results"), sel = 0, hits = [];
|
|
202
|
+
function search() {
|
|
203
|
+
var q = input.value.trim().toLowerCase();
|
|
204
|
+
hits = !q ? [] : S.model.tables.filter(function (t) {
|
|
205
|
+
return t.name.toLowerCase().indexOf(q) >= 0 || t.aliases.some(function (a) { return a.toLowerCase().indexOf(q) >= 0; });
|
|
206
|
+
}).slice(0, 50);
|
|
207
|
+
sel = 0;
|
|
208
|
+
results.innerHTML = hits.map(function (t, i) {
|
|
209
|
+
return '<div data-i="' + i + '"' + (i === 0 ? ' class="sel"' : "") + ">" + Q.esc(t.name) +
|
|
210
|
+
(t.aliases.length ? "<small>as " + Q.esc(t.aliases.join(", ")) + "</small>" : "") + "</div>";
|
|
211
|
+
}).join("");
|
|
212
|
+
results.classList.toggle("open", hits.length > 0);
|
|
213
|
+
}
|
|
214
|
+
function pick(i) {
|
|
215
|
+
var t = hits[i];
|
|
216
|
+
if (!t) return;
|
|
217
|
+
results.classList.remove("open");
|
|
218
|
+
input.blur();
|
|
219
|
+
centerOn(t.id);
|
|
220
|
+
}
|
|
221
|
+
input.addEventListener("input", search);
|
|
222
|
+
input.addEventListener("keydown", function (ev) {
|
|
223
|
+
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
|
224
|
+
sel = Math.max(0, Math.min(hits.length - 1, sel + (ev.key === "ArrowDown" ? 1 : -1)));
|
|
225
|
+
results.querySelectorAll("div").forEach(function (d, i) { d.classList.toggle("sel", i === sel); });
|
|
226
|
+
ev.preventDefault();
|
|
227
|
+
} else if (ev.key === "Enter") pick(sel);
|
|
228
|
+
else if (ev.key === "Escape") { input.value = ""; search(); input.blur(); }
|
|
229
|
+
});
|
|
230
|
+
results.addEventListener("mousedown", function (ev) {
|
|
231
|
+
var d = ev.target.closest("div[data-i]");
|
|
232
|
+
if (d) pick(+d.getAttribute("data-i"));
|
|
233
|
+
});
|
|
234
|
+
document.addEventListener("keydown", function (ev) {
|
|
235
|
+
if ((ev.key === "/" || (ev.key === "f" && (ev.metaKey || ev.ctrlKey))) && document.activeElement.tagName !== "TEXTAREA" &&
|
|
236
|
+
!(document.activeElement.closest && document.activeElement.closest(".CodeMirror"))) {
|
|
237
|
+
ev.preventDefault();
|
|
238
|
+
input.focus();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/* ------------------------------------------------------------- minimap */
|
|
243
|
+
var mini = document.getElementById("mini"), miniB = null;
|
|
244
|
+
function drawMinimap() {
|
|
245
|
+
miniB = Q.bounds(S.model, S.pos, S.mode);
|
|
246
|
+
var pad = 40, b = miniB;
|
|
247
|
+
mini.setAttribute("viewBox", (b[0] - pad) + " " + (b[1] - pad) + " " + (b[2] - b[0] + 2 * pad) + " " + (b[3] - b[1] + 2 * pad));
|
|
248
|
+
var geo = Q.geometry(S.model, S.mode), parts = [];
|
|
249
|
+
S.model.tables.forEach(function (t) {
|
|
250
|
+
var p = S.pos[t.id];
|
|
251
|
+
if (p) parts.push('<rect x="' + p[0] + '" y="' + p[1] + '" width="' + geo[t.id].w + '" height="' + geo[t.id].h + '" fill="#2b5797" opacity=".55"/>');
|
|
252
|
+
});
|
|
253
|
+
parts.push('<rect class="vpr" id="vpr"/>');
|
|
254
|
+
mini.innerHTML = parts.join("");
|
|
255
|
+
drawViewportRect();
|
|
256
|
+
}
|
|
257
|
+
function drawViewportRect() {
|
|
258
|
+
var r = document.getElementById("vpr");
|
|
259
|
+
if (!r) return;
|
|
260
|
+
var sz = stageSize(), v = S.view;
|
|
261
|
+
r.setAttribute("x", -v.x / v.k);
|
|
262
|
+
r.setAttribute("y", -v.y / v.k);
|
|
263
|
+
r.setAttribute("width", sz[0] / v.k);
|
|
264
|
+
r.setAttribute("height", sz[1] / v.k);
|
|
265
|
+
}
|
|
266
|
+
function miniPan(ev) {
|
|
267
|
+
var pt = mini.createSVGPoint();
|
|
268
|
+
pt.x = ev.clientX;
|
|
269
|
+
pt.y = ev.clientY;
|
|
270
|
+
var m = pt.matrixTransform(mini.getScreenCTM().inverse()), sz = stageSize();
|
|
271
|
+
S.view.x = sz[0] / 2 - m.x * S.view.k;
|
|
272
|
+
S.view.y = sz[1] / 2 - m.y * S.view.k;
|
|
273
|
+
applyView();
|
|
274
|
+
}
|
|
275
|
+
var miniDrag = false;
|
|
276
|
+
mini.addEventListener("pointerdown", function (ev) { miniDrag = true; mini.setPointerCapture(ev.pointerId); miniPan(ev); });
|
|
277
|
+
mini.addEventListener("pointermove", function (ev) { if (miniDrag) miniPan(ev); });
|
|
278
|
+
mini.addEventListener("pointerup", function () { miniDrag = false; });
|
|
279
|
+
|
|
280
|
+
/* ------------------------------------------------------------- export */
|
|
281
|
+
function download(name, blob) {
|
|
282
|
+
var a = document.createElement("a");
|
|
283
|
+
a.href = URL.createObjectURL(blob);
|
|
284
|
+
a.download = name;
|
|
285
|
+
document.body.appendChild(a);
|
|
286
|
+
a.click();
|
|
287
|
+
setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
|
288
|
+
}
|
|
289
|
+
function exportSvg() {
|
|
290
|
+
var only = S.focus ? Object.keys(neighbours(S.focus)) : null;
|
|
291
|
+
return Q.renderSvg(S.model, S.pos, { mode: S.mode, only: only, fontCss: data.fontCss });
|
|
292
|
+
}
|
|
293
|
+
document.getElementById("xsvg").onclick = function () {
|
|
294
|
+
download((data.title || "diagram") + ".svg", new Blob([exportSvg()], { type: "image/svg+xml" }));
|
|
295
|
+
};
|
|
296
|
+
document.getElementById("xpng").onclick = function () {
|
|
297
|
+
var text = exportSvg();
|
|
298
|
+
var m = /width="([\d.]+)" height="([\d.]+)"/.exec(text);
|
|
299
|
+
var w = +m[1], h = +m[2], scale = Math.min(2, 16000 / Math.max(w, h));
|
|
300
|
+
var img = new Image();
|
|
301
|
+
img.onload = function () {
|
|
302
|
+
var c = document.createElement("canvas");
|
|
303
|
+
c.width = Math.round(w * scale);
|
|
304
|
+
c.height = Math.round(h * scale);
|
|
305
|
+
var ctx = c.getContext("2d");
|
|
306
|
+
ctx.scale(scale, scale);
|
|
307
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
308
|
+
c.toBlob(function (b) { download((data.title || "diagram") + ".png", b); }, "image/png");
|
|
309
|
+
};
|
|
310
|
+
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(text);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/* ------------------------------------------------------------- public API (serve mode) */
|
|
314
|
+
window.qdbdViewer = {
|
|
315
|
+
setData: function (model, positions) {
|
|
316
|
+
S.model = model;
|
|
317
|
+
S.pos = positions;
|
|
318
|
+
index();
|
|
319
|
+
render();
|
|
320
|
+
applyView();
|
|
321
|
+
},
|
|
322
|
+
state: S,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
index();
|
|
326
|
+
if (S.serve) document.body.classList.add("serve");
|
|
327
|
+
render();
|
|
328
|
+
if (S.model.zoom) {
|
|
329
|
+
var k = S.model.zoom / 100, p = S.model.position || [0, 0];
|
|
330
|
+
S.view = { k: k, x: -p[0] * k, y: -p[1] * k };
|
|
331
|
+
applyView();
|
|
332
|
+
if (!S.model.position) fit();
|
|
333
|
+
} else {
|
|
334
|
+
fit();
|
|
335
|
+
}
|
|
336
|
+
window.addEventListener("resize", drawViewportRect);
|
|
337
|
+
})();
|
quickdbd_cli/cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""`qdbd` command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .model import Schema
|
|
11
|
+
from .project import Project
|
|
12
|
+
|
|
13
|
+
OK = "No errors found. Good job!"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _print_diagnostics(schema: Schema, strict: bool) -> bool:
|
|
17
|
+
"""Print diagnostics; returns True when the schema is usable."""
|
|
18
|
+
for d in schema.diagnostics:
|
|
19
|
+
print(d.format(), file=sys.stderr)
|
|
20
|
+
failed = bool(schema.errors) or (strict and bool(schema.warnings))
|
|
21
|
+
if failed:
|
|
22
|
+
n_e, n_w = len(schema.errors), len(schema.warnings)
|
|
23
|
+
print(f"{n_e} error(s), {n_w} warning(s)", file=sys.stderr)
|
|
24
|
+
return not failed
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _open(args) -> Project:
|
|
28
|
+
return Project.open([Path(p) for p in args.paths], Path(args.layout) if getattr(args, "layout", None) else None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _title(project: Project) -> str:
|
|
32
|
+
if len(project.inputs) == 1:
|
|
33
|
+
p = project.inputs[0]
|
|
34
|
+
return p.stem if p.is_file() else (p.resolve().name or "diagram")
|
|
35
|
+
return "diagram"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_check(args) -> int:
|
|
39
|
+
project = _open(args)
|
|
40
|
+
schema = project.compile()
|
|
41
|
+
ok = _print_diagnostics(schema, args.strict)
|
|
42
|
+
if ok and not schema.diagnostics:
|
|
43
|
+
print(OK)
|
|
44
|
+
elif ok:
|
|
45
|
+
print(f"No errors found ({len(schema.warnings)} warning(s)).")
|
|
46
|
+
return 0 if ok else 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_build(args) -> int:
|
|
50
|
+
from .html import render_page
|
|
51
|
+
|
|
52
|
+
project = _open(args)
|
|
53
|
+
schema = project.compile()
|
|
54
|
+
if not _print_diagnostics(schema, args.strict):
|
|
55
|
+
return 1
|
|
56
|
+
model, pos = project.build(schema)
|
|
57
|
+
out = Path(args.output)
|
|
58
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
out.write_text(render_page(model, pos, _title(project)), encoding="utf-8")
|
|
60
|
+
print(f"Wrote {out} ({len(model['tables'])} tables, {len(model['rels'])} relationships)")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_export(args) -> int:
|
|
65
|
+
from . import export
|
|
66
|
+
|
|
67
|
+
project = _open(args)
|
|
68
|
+
schema = project.compile()
|
|
69
|
+
if not _print_diagnostics(schema, args.strict):
|
|
70
|
+
return 1
|
|
71
|
+
model, pos = project.build(schema)
|
|
72
|
+
only = None
|
|
73
|
+
if args.focus:
|
|
74
|
+
t = export.find_table(model, args.focus)
|
|
75
|
+
if t is None:
|
|
76
|
+
print(f"error: table '{args.focus}' not found", file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
only = export.focus_ids(model, t["id"], args.depth)
|
|
79
|
+
pos = export.focus_positions(model, pos, only)
|
|
80
|
+
out = Path(args.output)
|
|
81
|
+
fmt = out.suffix.lower().lstrip(".")
|
|
82
|
+
if fmt not in ("svg", "png"):
|
|
83
|
+
print("error: output must end in .svg or .png", file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
svg = export.render_svg(model, pos, only=only, mode=args.mode, embed_font=(fmt == "svg"))
|
|
86
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
if fmt == "svg":
|
|
88
|
+
out.write_text(svg, encoding="utf-8")
|
|
89
|
+
else:
|
|
90
|
+
out.write_bytes(export.svg_to_png(svg, args.scale))
|
|
91
|
+
print(f"Wrote {out} ({len(only) if only else len(model['tables'])} tables)")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_layout(args) -> int:
|
|
96
|
+
project = _open(args)
|
|
97
|
+
schema = project.compile()
|
|
98
|
+
if not _print_diagnostics(schema, False):
|
|
99
|
+
return 1
|
|
100
|
+
model, pos = project.build(schema, reset=args.reset)
|
|
101
|
+
project.save_positions(model, pos)
|
|
102
|
+
print(f"Wrote {project.sidecar} ({len(pos)} tables)")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_serve(args) -> int:
|
|
107
|
+
from .serve import serve
|
|
108
|
+
|
|
109
|
+
project = _open(args)
|
|
110
|
+
return serve(project, port=args.port, open_browser=not args.no_open)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
114
|
+
p = argparse.ArgumentParser(prog="qdbd", description="Compile QuickDBD schema files into interactive ER diagrams.")
|
|
115
|
+
p.add_argument("--version", action="version", version=f"qdbd {__version__}")
|
|
116
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
117
|
+
|
|
118
|
+
def paths(sp):
|
|
119
|
+
sp.add_argument("paths", nargs="+", metavar="PATH", help="schema .txt file(s) or folder(s) (recursive)")
|
|
120
|
+
|
|
121
|
+
sp = sub.add_parser("check", help="validate schema files")
|
|
122
|
+
paths(sp)
|
|
123
|
+
sp.add_argument("--strict", action="store_true", help="treat warnings (QuickDBD incompatibilities) as errors")
|
|
124
|
+
sp.set_defaults(func=cmd_check)
|
|
125
|
+
|
|
126
|
+
sp = sub.add_parser("build", help="write an interactive HTML diagram")
|
|
127
|
+
paths(sp)
|
|
128
|
+
sp.add_argument("-o", "--output", default="diagram.html")
|
|
129
|
+
sp.add_argument("--layout", help="layout sidecar file (default: next to the schema)")
|
|
130
|
+
sp.add_argument("--strict", action="store_true")
|
|
131
|
+
sp.set_defaults(func=cmd_build)
|
|
132
|
+
|
|
133
|
+
sp = sub.add_parser("export", help="write a PNG or SVG image")
|
|
134
|
+
paths(sp)
|
|
135
|
+
sp.add_argument("-o", "--output", required=True, help="out.png or out.svg")
|
|
136
|
+
sp.add_argument("--focus", metavar="TABLE", help="only this table and its neighbours")
|
|
137
|
+
sp.add_argument("--depth", type=int, default=1, help="relationship hops around --focus (default 1)")
|
|
138
|
+
sp.add_argument("--mode", choices=["full", "keys", "header"], default="full", help="collapse mode")
|
|
139
|
+
sp.add_argument("--scale", type=float, default=2.0, help="PNG pixel scale (default 2)")
|
|
140
|
+
sp.add_argument("--layout")
|
|
141
|
+
sp.add_argument("--strict", action="store_true")
|
|
142
|
+
sp.set_defaults(func=cmd_export)
|
|
143
|
+
|
|
144
|
+
sp = sub.add_parser("layout", help="write table positions to the layout sidecar")
|
|
145
|
+
paths(sp)
|
|
146
|
+
sp.add_argument("--reset", action="store_true", help="discard saved positions and auto-layout everything")
|
|
147
|
+
sp.add_argument("--layout")
|
|
148
|
+
sp.set_defaults(func=cmd_layout)
|
|
149
|
+
|
|
150
|
+
sp = sub.add_parser("serve", help="live editor: reload on save, drag tables to arrange")
|
|
151
|
+
paths(sp)
|
|
152
|
+
sp.add_argument("--port", type=int, default=8765)
|
|
153
|
+
sp.add_argument("--layout")
|
|
154
|
+
sp.add_argument("--no-open", action="store_true", help="don't open a browser")
|
|
155
|
+
sp.set_defaults(func=cmd_serve)
|
|
156
|
+
return p
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main(argv: list[str] | None = None) -> int:
|
|
160
|
+
args = build_parser().parse_args(argv)
|
|
161
|
+
try:
|
|
162
|
+
return args.func(args)
|
|
163
|
+
except FileNotFoundError as e:
|
|
164
|
+
print(f"error: {e}", file=sys.stderr)
|
|
165
|
+
return 2
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
sys.exit(main())
|
quickdbd_cli/export.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Static SVG / PNG export via render.js (in V8) and resvg."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from functools import cache
|
|
7
|
+
from importlib import resources
|
|
8
|
+
|
|
9
|
+
from . import layout
|
|
10
|
+
from .jsrt import runtime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _asset_bytes(name: str) -> bytes:
|
|
14
|
+
return resources.files("quickdbd_cli").joinpath(f"assets/{name}").read_bytes()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cache
|
|
18
|
+
def font_css() -> str:
|
|
19
|
+
out = []
|
|
20
|
+
for weight, name in (("normal", "Inter-Regular.woff2"), ("bold", "Inter-Bold.woff2")):
|
|
21
|
+
b64 = base64.b64encode(_asset_bytes(name)).decode("ascii")
|
|
22
|
+
out.append(
|
|
23
|
+
f"@font-face{{font-family:Inter;font-weight:{weight};"
|
|
24
|
+
f"src:url(data:font/woff2;base64,{b64}) format('woff2');}}"
|
|
25
|
+
)
|
|
26
|
+
return "".join(out)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_table(model: dict, name: str) -> dict | None:
|
|
30
|
+
for key in ("name", "id"):
|
|
31
|
+
for t in model["tables"]:
|
|
32
|
+
if t[key] == name:
|
|
33
|
+
return t
|
|
34
|
+
low = name.lower()
|
|
35
|
+
for t in model["tables"]:
|
|
36
|
+
if t["name"].lower() == low or low in (a.lower() for a in t["aliases"]):
|
|
37
|
+
return t
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def focus_ids(model: dict, table_id: str, depth: int) -> list[str]:
|
|
42
|
+
rt = runtime()
|
|
43
|
+
rt.load("render.js")
|
|
44
|
+
return rt.call("qdbdNeighbourhood", model, table_id, depth)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_svg(
|
|
48
|
+
model: dict, positions: dict, only: list[str] | None = None, mode: str = "full", embed_font: bool = True
|
|
49
|
+
) -> str:
|
|
50
|
+
rt = runtime()
|
|
51
|
+
rt.load("render.js")
|
|
52
|
+
opts = {"mode": mode, "only": only}
|
|
53
|
+
if embed_font:
|
|
54
|
+
opts["fontCss"] = font_css()
|
|
55
|
+
return rt.call("qdbdRenderSvg", model, positions, opts)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def focus_positions(model: dict, positions: dict, ids: list[str]) -> dict:
|
|
59
|
+
"""Fresh compact ELK layout for a focus subset, so the image isn't mostly empty space."""
|
|
60
|
+
keep = set(ids)
|
|
61
|
+
tables = [t for t in model["tables"] if t["id"] in keep]
|
|
62
|
+
rels = [r for r in model["rels"] if r["a"]["t"] in keep and r["b"]["t"] in keep]
|
|
63
|
+
pos = layout.elk_layout(tables, rels)
|
|
64
|
+
return {k: [v[0], v[1]] for k, v in pos.items()}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def svg_to_png(svg: str, scale: float = 2.0) -> bytes:
|
|
68
|
+
import resvg_py
|
|
69
|
+
|
|
70
|
+
fonts_dir = resources.files("quickdbd_cli").joinpath("assets")
|
|
71
|
+
font_files = [str(fonts_dir.joinpath(n)) for n in ("Inter-Regular.ttf", "Inter-Bold.ttf")]
|
|
72
|
+
data = resvg_py.svg_to_bytes(
|
|
73
|
+
svg_string=svg,
|
|
74
|
+
font_files=font_files,
|
|
75
|
+
skip_system_fonts=True,
|
|
76
|
+
sans_serif_family="Inter",
|
|
77
|
+
zoom=scale,
|
|
78
|
+
)
|
|
79
|
+
return bytes(data)
|
quickdbd_cli/html.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Self-contained HTML page: model + positions + renderer + viewer, all inline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from importlib import resources
|
|
7
|
+
|
|
8
|
+
from .export import font_css
|
|
9
|
+
|
|
10
|
+
CODEMIRROR = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _asset(name: str) -> str:
|
|
14
|
+
return resources.files("quickdbd_cli").joinpath(f"assets/{name}").read_text(encoding="utf-8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _json_for_script(obj) -> str:
|
|
18
|
+
# Safe inside <script type="application/json">: no "</" sequences.
|
|
19
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).replace("</", "<\\/")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _escape(text: str) -> str:
|
|
23
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
BODY = """
|
|
27
|
+
<div id="app">
|
|
28
|
+
{side}
|
|
29
|
+
<div id="stage">
|
|
30
|
+
<svg id="canvas" xmlns="http://www.w3.org/2000/svg"><g id="vp"></g></svg>
|
|
31
|
+
<div class="search">
|
|
32
|
+
<input id="q" type="search" placeholder="Search {count} tables ( / )" autocomplete="off" spellcheck="false">
|
|
33
|
+
<div id="results" class="results"></div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="bar">
|
|
36
|
+
<div class="seg" id="modes" title="Collapse tables">
|
|
37
|
+
<button data-mode="full" class="on">Full</button>
|
|
38
|
+
<button data-mode="keys">Keys</button>
|
|
39
|
+
<button data-mode="header">Names</button>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="sep"></div>
|
|
42
|
+
<button id="xsvg" title="Export SVG">SVG</button>
|
|
43
|
+
<button id="xpng" title="Export PNG">PNG</button>
|
|
44
|
+
<div class="sep"></div>
|
|
45
|
+
<button id="zout" title="Zoom out">−</button>
|
|
46
|
+
<span class="zoomval" id="zoomval">100%</span>
|
|
47
|
+
<button id="zin" title="Zoom in">+</button>
|
|
48
|
+
<button id="zfit" title="Fit to screen">◎</button>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="minimap">
|
|
51
|
+
<svg id="mini" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"></svg>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="hint">{hint}</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
SIDE = """
|
|
59
|
+
<div id="side">
|
|
60
|
+
<div class="tabs" id="tabs"></div>
|
|
61
|
+
<div class="banner" id="banner">File changed on disk while you have unsaved edits.
|
|
62
|
+
<button id="keepmine">Keep mine</button><button id="reload">Reload from disk</button></div>
|
|
63
|
+
<div id="editor"></div>
|
|
64
|
+
<div id="problems"></div>
|
|
65
|
+
</div>
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render_page(model: dict, positions: dict, title: str, serve: bool = False) -> str:
|
|
70
|
+
data = {"model": model, "positions": positions, "serve": serve, "title": title, "fontCss": font_css()}
|
|
71
|
+
hint = (
|
|
72
|
+
"Drag tables to move them (saved). Click a table to focus. Esc clears."
|
|
73
|
+
if serve
|
|
74
|
+
else "Click a table to focus its relationships. Esc clears. Scroll to zoom, drag to pan."
|
|
75
|
+
)
|
|
76
|
+
body = BODY.format(side=SIDE if serve else "", count=len(model["tables"]), hint=hint)
|
|
77
|
+
head_extra = ""
|
|
78
|
+
scripts = ""
|
|
79
|
+
if serve:
|
|
80
|
+
head_extra = f'<link rel="stylesheet" href="{CODEMIRROR}/codemirror.min.css">'
|
|
81
|
+
scripts = (
|
|
82
|
+
f'<script src="{CODEMIRROR}/codemirror.min.js"></script>'
|
|
83
|
+
f'<script src="{CODEMIRROR}/addon/mode/simple.min.js"></script>'
|
|
84
|
+
f"<script>{_asset('editor.js')}</script>"
|
|
85
|
+
)
|
|
86
|
+
return (
|
|
87
|
+
'<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
|
|
88
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
89
|
+
'<meta name="generator" content="qdbd">'
|
|
90
|
+
f"<title>{_escape(title)}</title>"
|
|
91
|
+
f"<style>{font_css()}{_asset('style.css')}</style>{head_extra}</head><body>"
|
|
92
|
+
f"{body}"
|
|
93
|
+
f'<script type="application/json" id="qdbd-data">{_json_for_script(data)}</script>'
|
|
94
|
+
f"<script>{_asset('render.js')}</script>"
|
|
95
|
+
f"<script>{_asset('viewer.js')}</script>"
|
|
96
|
+
f"{scripts}</body></html>\n"
|
|
97
|
+
)
|