quickdbd-cli 0.1.0__tar.gz → 0.1.2__tar.gz
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-0.1.0 → quickdbd_cli-0.1.2}/PKG-INFO +13 -1
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/README.md +12 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/qdbd.layout.json +14 -14
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/pyproject.toml +1 -1
- quickdbd_cli-0.1.2/scripts/release.sh +28 -0
- quickdbd_cli-0.1.2/src/quickdbd_cli/__init__.py +1 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/render.js +15 -4
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/style.css +9 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/viewer.js +46 -3
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/html.py +6 -2
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/measure.py +10 -3
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/serve.py +21 -8
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/tests/test_pipeline.py +51 -0
- quickdbd_cli-0.1.2/tests/test_serve.py +160 -0
- quickdbd_cli-0.1.0/src/quickdbd_cli/__init__.py +0 -1
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/.gitignore +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/LICENSE +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/NOTICE +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/quickdbd-sample.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/finance.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/hr.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/inventory.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/marketing.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/sales.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/scripts/gen_font_metrics.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/scripts/gen_schema.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/__main__.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/editor.js +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/font-metrics.json +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/cli.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/export.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/jsrt.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/layout.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/model.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/parser.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/project.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/tests/test_parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quickdbd-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Compile QuickDBD-syntax schema files into interactive HTML ER diagrams, with no table limit.
|
|
5
5
|
Project-URL: Homepage, https://github.com/MarkPhamm/quickdbd-cli
|
|
6
6
|
Author: Minh (Mark) Pham
|
|
@@ -55,6 +55,7 @@ Not affiliated with QuickDBD. It reads the same text syntax, so schemas move fre
|
|
|
55
55
|
- click a table to focus on it and its neighbours
|
|
56
56
|
- collapse tables: full / keys only / names only
|
|
57
57
|
- hover a column to highlight its relationship
|
|
58
|
+
- hover a dotted-underlined table or column to read its `#` doc comments
|
|
58
59
|
- PNG/SVG export buttons
|
|
59
60
|
- **Live editing:** `qdbd serve` reloads when you save, has a browser editor pane with autosave, and remembers where you drag tables.
|
|
60
61
|
- **Many files, one diagram:** point it at a folder and foreign keys can cross files.
|
|
@@ -148,6 +149,17 @@ python scripts/gen_font_metrics.py # regenerate font width table
|
|
|
148
149
|
|
|
149
150
|
Design notes: `docs/superpowers/specs/2026-10-17-qdbd-design.md`.
|
|
150
151
|
|
|
152
|
+
### Releasing
|
|
153
|
+
|
|
154
|
+
Put PyPI credentials in `.env` (gitignored):
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
TWINE_USERNAME=__token__
|
|
158
|
+
TWINE_PASSWORD=pypi-...
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Bump `version` in `pyproject.toml` and `src/quickdbd_cli/__init__.py`, commit, then run `PATH="$PWD/.venv/bin:$PATH" scripts/release.sh`. It builds, uploads to PyPI, tags `vX.Y.Z` and creates the GitHub release.
|
|
162
|
+
|
|
151
163
|
## License
|
|
152
164
|
|
|
153
165
|
Apache-2.0. Bundled third-party files are listed in [NOTICE](NOTICE).
|
|
@@ -30,6 +30,7 @@ Not affiliated with QuickDBD. It reads the same text syntax, so schemas move fre
|
|
|
30
30
|
- click a table to focus on it and its neighbours
|
|
31
31
|
- collapse tables: full / keys only / names only
|
|
32
32
|
- hover a column to highlight its relationship
|
|
33
|
+
- hover a dotted-underlined table or column to read its `#` doc comments
|
|
33
34
|
- PNG/SVG export buttons
|
|
34
35
|
- **Live editing:** `qdbd serve` reloads when you save, has a browser editor pane with autosave, and remembers where you drag tables.
|
|
35
36
|
- **Many files, one diagram:** point it at a folder and foreign keys can cross files.
|
|
@@ -123,6 +124,17 @@ python scripts/gen_font_metrics.py # regenerate font width table
|
|
|
123
124
|
|
|
124
125
|
Design notes: `docs/superpowers/specs/2026-10-17-qdbd-design.md`.
|
|
125
126
|
|
|
127
|
+
### Releasing
|
|
128
|
+
|
|
129
|
+
Put PyPI credentials in `.env` (gitignored):
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
TWINE_USERNAME=__token__
|
|
133
|
+
TWINE_PASSWORD=pypi-...
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Bump `version` in `pyproject.toml` and `src/quickdbd_cli/__init__.py`, commit, then run `PATH="$PWD/.venv/bin:$PATH" scripts/release.sh`. It builds, uploads to PyPI, tags `vX.Y.Z` and creates the GitHub release.
|
|
137
|
+
|
|
126
138
|
## License
|
|
127
139
|
|
|
128
140
|
Apache-2.0. Bundled third-party files are listed in [NOTICE](NOTICE).
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"y": 5795
|
|
30
30
|
},
|
|
31
31
|
"Agency": {
|
|
32
|
-
"x":
|
|
33
|
-
"y":
|
|
32
|
+
"x": 603,
|
|
33
|
+
"y": 3752
|
|
34
34
|
},
|
|
35
35
|
"Allocation": {
|
|
36
36
|
"x": 1655,
|
|
@@ -245,8 +245,8 @@
|
|
|
245
245
|
"y": 5393
|
|
246
246
|
},
|
|
247
247
|
"EmailOpen": {
|
|
248
|
-
"x":
|
|
249
|
-
"y":
|
|
248
|
+
"x": 3395,
|
|
249
|
+
"y": 2767
|
|
250
250
|
},
|
|
251
251
|
"EmailSend": {
|
|
252
252
|
"x": 1927,
|
|
@@ -325,8 +325,8 @@
|
|
|
325
325
|
"y": 4608
|
|
326
326
|
},
|
|
327
327
|
"Holiday": {
|
|
328
|
-
"x":
|
|
329
|
-
"y":
|
|
328
|
+
"x": 3280,
|
|
329
|
+
"y": 3035
|
|
330
330
|
},
|
|
331
331
|
"Impression": {
|
|
332
332
|
"x": 2544,
|
|
@@ -393,8 +393,8 @@
|
|
|
393
393
|
"y": 6657
|
|
394
394
|
},
|
|
395
395
|
"Ledger": {
|
|
396
|
-
"x":
|
|
397
|
-
"y":
|
|
396
|
+
"x": 3541,
|
|
397
|
+
"y": 2427
|
|
398
398
|
},
|
|
399
399
|
"LedgerEntry": {
|
|
400
400
|
"x": 2512,
|
|
@@ -573,16 +573,16 @@
|
|
|
573
573
|
"y": 3695
|
|
574
574
|
},
|
|
575
575
|
"Reseller": {
|
|
576
|
-
"x":
|
|
577
|
-
"y":
|
|
576
|
+
"x": 3043,
|
|
577
|
+
"y": 2791
|
|
578
578
|
},
|
|
579
579
|
"Reservation": {
|
|
580
580
|
"x": 3614,
|
|
581
581
|
"y": 1303
|
|
582
582
|
},
|
|
583
583
|
"Return": {
|
|
584
|
-
"x":
|
|
585
|
-
"y":
|
|
584
|
+
"x": 1113,
|
|
585
|
+
"y": 3858
|
|
586
586
|
},
|
|
587
587
|
"Review": {
|
|
588
588
|
"x": 604,
|
|
@@ -761,8 +761,8 @@
|
|
|
761
761
|
"y": 5153
|
|
762
762
|
},
|
|
763
763
|
"hr.Training": {
|
|
764
|
-
"x":
|
|
765
|
-
"y":
|
|
764
|
+
"x": 1357,
|
|
765
|
+
"y": 3813
|
|
766
766
|
},
|
|
767
767
|
"inventory.Container": {
|
|
768
768
|
"x": 2503,
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "quickdbd-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Compile QuickDBD-syntax schema files into interactive HTML ER diagrams, with no table limit."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build and upload the current version to PyPI using credentials from .env,
|
|
3
|
+
# then tag and create the GitHub release.
|
|
4
|
+
# Usage: bump `version` in pyproject.toml and src/quickdbd_cli/__init__.py, commit, then run
|
|
5
|
+
# PATH="$PWD/.venv/bin:$PATH" scripts/release.sh
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
cd "$(dirname "$0")/.."
|
|
8
|
+
|
|
9
|
+
[ -f .env ] || { echo "error: .env with TWINE_USERNAME/TWINE_PASSWORD not found" >&2; exit 1; }
|
|
10
|
+
set -a; . ./.env; set +a
|
|
11
|
+
|
|
12
|
+
version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
13
|
+
[ -z "$(git status --porcelain --untracked-files=no)" ] || { echo "error: uncommitted changes" >&2; exit 1; }
|
|
14
|
+
|
|
15
|
+
rm -rf dist
|
|
16
|
+
# uv-created venvs have no pip; only install the tools when they're missing.
|
|
17
|
+
python3 -c "import build, twine" 2>/dev/null || {
|
|
18
|
+
if command -v uv >/dev/null; then uv pip install -q --python "$(command -v python3)" build twine
|
|
19
|
+
else python3 -m pip install -q --upgrade build twine; fi
|
|
20
|
+
}
|
|
21
|
+
python3 -m build
|
|
22
|
+
python3 -m twine check dist/*
|
|
23
|
+
python3 -m twine upload --non-interactive dist/*
|
|
24
|
+
|
|
25
|
+
git tag -a "v$version" -m "qdbd v$version"
|
|
26
|
+
git push origin "v$version"
|
|
27
|
+
gh release create "v$version" dist/* --title "qdbd v$version" --generate-notes
|
|
28
|
+
echo "Released v$version: https://pypi.org/project/quickdbd-cli/$version/"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
|
@@ -66,13 +66,22 @@
|
|
|
66
66
|
'<path d="M' + n(x + 6.5) + " " + n(cy) + "h7m-2 0v3m-2.5-3v2" + '"/></g>';
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
/* Dotted underline marking text that has a doc comment (interactive viewer only). */
|
|
70
|
+
function docMark(x, y, w, color) {
|
|
71
|
+
return '<line class="dm" x1="' + n(x) + '" y1="' + n(y) + '" x2="' + n(x + w) + '" y2="' + n(y) +
|
|
72
|
+
'" stroke="' + color + '" stroke-width="1" stroke-dasharray="1.5 2"/>';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderTable(model, t, g, p, docMarks) {
|
|
70
76
|
var st = model.style, w = g.w, h = g.h, out = [];
|
|
71
77
|
out.push('<g class="t" data-id="' + esc(t.id) + '" transform="translate(' + n(p[0]) + "," + n(p[1]) + ')">');
|
|
72
78
|
out.push('<rect class="tb" width="' + w + '" height="' + h + '" rx="2" fill="' + C.body + '" stroke="' + C.border + '"/>');
|
|
73
79
|
out.push('<path class="th" d="M0 2a2 2 0 0 1 2-2h' + (w - 4) + "a2 2 0 0 1 2 2v" + (st.headerH - 2) + "h-" + w + 'z" fill="' + C.header + '"/>');
|
|
74
80
|
out.push('<text x="' + st.padX + '" y="' + n(st.headerH / 2 + st.headerSize * 0.36) + '" fill="' + C.headerText +
|
|
75
81
|
'" font-family="' + FONT + '" font-size="' + st.headerSize + '">' + esc(t.name) + "</text>");
|
|
82
|
+
if (docMarks && t.doc) {
|
|
83
|
+
out.push(docMark(st.padX, st.headerH / 2 + st.headerSize * 0.36 + 3, t.nameW, "rgba(255,255,255,.7)"));
|
|
84
|
+
}
|
|
76
85
|
g.cols.forEach(function (c, i) {
|
|
77
86
|
var y = st.headerH + i * st.rowH, cy = y + st.rowH / 2, base = n(cy + st.rowSize * 0.36);
|
|
78
87
|
var weight = c.pk || c.fk ? ' font-weight="bold"' : "";
|
|
@@ -81,6 +90,7 @@
|
|
|
81
90
|
out.push('<rect class="rb" y="' + y + '" width="' + w + '" height="' + st.rowH + '" fill="' + C.body + '" fill-opacity="0"/>');
|
|
82
91
|
out.push('<text x="' + st.padX + '" y="' + base + '" fill="' + color + '" font-family="' + FONT + '" font-size="' +
|
|
83
92
|
st.rowSize + '"' + weight + ">" + esc(c.name) + "</text>");
|
|
93
|
+
if (docMarks && c.doc) out.push(docMark(st.padX, cy + st.rowSize * 0.36 + 2.5, c.nameW, color));
|
|
84
94
|
var bx = st.padX + c.nameW + 6;
|
|
85
95
|
c.badges.forEach(function (b) {
|
|
86
96
|
var bh = st.badgeSize + 5;
|
|
@@ -164,9 +174,10 @@
|
|
|
164
174
|
return model.rels.map(function (r) { return renderEdge(model, geo, pos, r); }).join("");
|
|
165
175
|
}
|
|
166
176
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
var
|
|
177
|
+
/* opts.docMarks: underline tables/columns that have doc comments (viewer only, not exports). */
|
|
178
|
+
function renderInner(model, pos, mode, opts) {
|
|
179
|
+
var geo = geometry(model, mode), marks = !!(opts && opts.docMarks);
|
|
180
|
+
var tables = model.tables.map(function (t) { return pos[t.id] ? renderTable(model, t, geo[t.id], pos[t.id], marks) : ""; }).join("");
|
|
170
181
|
return '<g class="edges">' + renderEdges(model, pos, mode, geo) + '</g><g class="tables">' + tables + "</g>";
|
|
171
182
|
}
|
|
172
183
|
|
|
@@ -53,6 +53,15 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
|
|
|
53
53
|
.minimap svg { width: 100%; height: 100%; display: block; }
|
|
54
54
|
.minimap .vpr { fill: rgba(43,87,151,.12); stroke: var(--blue); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
|
|
55
55
|
|
|
56
|
+
/* doc-comment tooltip */
|
|
57
|
+
.tip { position: absolute; display: none; max-width: 340px; background: #23272b; color: #eee; border-radius: 6px;
|
|
58
|
+
padding: 8px 10px; font-size: 12px; line-height: 1.45; pointer-events: none; box-shadow: 0 4px 14px rgba(0,0,0,.25);
|
|
59
|
+
z-index: 5; }
|
|
60
|
+
.tip.open { display: block; }
|
|
61
|
+
.tip .tt { font-weight: bold; margin-bottom: 3px; }
|
|
62
|
+
.tip .tt span { font-weight: normal; color: #9aa4ad; margin-left: 8px; }
|
|
63
|
+
.tip .td { white-space: pre-wrap; color: #d8dde2; }
|
|
64
|
+
|
|
56
65
|
.hint { position: absolute; left: 16px; bottom: 14px; color: var(--muted); font-size: 12px; }
|
|
57
66
|
|
|
58
67
|
/* serve: editor pane */
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
/* ------------------------------------------------------------- render */
|
|
26
26
|
function render() {
|
|
27
|
-
vp.innerHTML = Q.renderInner(S.model, S.pos, S.mode);
|
|
27
|
+
vp.innerHTML = Q.renderInner(S.model, S.pos, S.mode, { docMarks: true });
|
|
28
28
|
applyFocus();
|
|
29
29
|
drawMinimap();
|
|
30
30
|
}
|
|
@@ -124,7 +124,50 @@
|
|
|
124
124
|
if (t) t.classList.add("hl");
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
|
-
vp.addEventListener("mouseleave", clearHover);
|
|
127
|
+
vp.addEventListener("mouseleave", function () { clearHover(); hideTip(); });
|
|
128
|
+
|
|
129
|
+
/* ------------------------------------------------------------- doc-comment tooltips */
|
|
130
|
+
var tip = document.getElementById("tip");
|
|
131
|
+
function hideTip() { tip.classList.remove("open"); }
|
|
132
|
+
function showTip(ev, title, sub, doc) {
|
|
133
|
+
tip.innerHTML = "";
|
|
134
|
+
var h = document.createElement("div");
|
|
135
|
+
h.className = "tt";
|
|
136
|
+
h.textContent = title;
|
|
137
|
+
if (sub) {
|
|
138
|
+
var s = document.createElement("span");
|
|
139
|
+
s.textContent = sub;
|
|
140
|
+
h.appendChild(s);
|
|
141
|
+
}
|
|
142
|
+
var d = document.createElement("div");
|
|
143
|
+
d.className = "td";
|
|
144
|
+
d.textContent = doc;
|
|
145
|
+
tip.appendChild(h);
|
|
146
|
+
tip.appendChild(d);
|
|
147
|
+
tip.classList.add("open");
|
|
148
|
+
moveTip(ev);
|
|
149
|
+
}
|
|
150
|
+
function moveTip(ev) {
|
|
151
|
+
var r = svg.getBoundingClientRect(), x = ev.clientX - r.left + 14, y = ev.clientY - r.top + 14;
|
|
152
|
+
var w = tip.offsetWidth, h = tip.offsetHeight;
|
|
153
|
+
if (x + w > r.width - 8) x = ev.clientX - r.left - w - 14;
|
|
154
|
+
if (y + h > r.height - 8) y = ev.clientY - r.top - h - 14;
|
|
155
|
+
tip.style.left = Math.max(8, x) + "px";
|
|
156
|
+
tip.style.top = Math.max(8, y) + "px";
|
|
157
|
+
}
|
|
158
|
+
vp.addEventListener("mousemove", function (ev) {
|
|
159
|
+
if (drag && drag.moved) { hideTip(); return; }
|
|
160
|
+
var tEl = ev.target.closest && ev.target.closest(".t");
|
|
161
|
+
if (!tEl) { hideTip(); return; }
|
|
162
|
+
var t = byId[tEl.getAttribute("data-id")], row = ev.target.closest(".r");
|
|
163
|
+
if (row) {
|
|
164
|
+
var cid = row.getAttribute("data-c"), c = t.cols.filter(function (x) { return x.id === cid; })[0];
|
|
165
|
+
if (c && c.doc) showTip(ev, t.name + "." + c.name, c.type, c.doc);
|
|
166
|
+
else hideTip();
|
|
167
|
+
} else if (t.doc && ev.target.closest(".th, text")) {
|
|
168
|
+
showTip(ev, t.name, t.aliases.length ? "as " + t.aliases.join(", ") : "", t.doc);
|
|
169
|
+
} else hideTip();
|
|
170
|
+
});
|
|
128
171
|
|
|
129
172
|
/* ------------------------------------------------------------- pointer: pan, click, drag */
|
|
130
173
|
var drag = null;
|
|
@@ -288,7 +331,7 @@
|
|
|
288
331
|
}
|
|
289
332
|
function exportSvg() {
|
|
290
333
|
var only = S.focus ? Object.keys(neighbours(S.focus)) : null;
|
|
291
|
-
return Q.renderSvg(S.model, S.pos, { mode: S.mode, only: only, fontCss:
|
|
334
|
+
return Q.renderSvg(S.model, S.pos, { mode: S.mode, only: only, fontCss: document.getElementById("qdbd-font").textContent });
|
|
292
335
|
}
|
|
293
336
|
document.getElementById("xsvg").onclick = function () {
|
|
294
337
|
download((data.title || "diagram") + ".svg", new Blob([exportSvg()], { type: "image/svg+xml" }));
|
|
@@ -50,6 +50,7 @@ BODY = """
|
|
|
50
50
|
<div class="minimap">
|
|
51
51
|
<svg id="mini" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"></svg>
|
|
52
52
|
</div>
|
|
53
|
+
<div class="tip" id="tip" role="tooltip"></div>
|
|
53
54
|
<div class="hint">{hint}</div>
|
|
54
55
|
</div>
|
|
55
56
|
</div>
|
|
@@ -67,7 +68,7 @@ SIDE = """
|
|
|
67
68
|
|
|
68
69
|
|
|
69
70
|
def render_page(model: dict, positions: dict, title: str, serve: bool = False) -> str:
|
|
70
|
-
data = {"model": model, "positions": positions, "serve": serve, "title": title
|
|
71
|
+
data = {"model": model, "positions": positions, "serve": serve, "title": title}
|
|
71
72
|
hint = (
|
|
72
73
|
"Drag tables to move them (saved). Click a table to focus. Esc clears."
|
|
73
74
|
if serve
|
|
@@ -88,7 +89,10 @@ def render_page(model: dict, positions: dict, title: str, serve: bool = False) -
|
|
|
88
89
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
89
90
|
'<meta name="generator" content="qdbd">'
|
|
90
91
|
f"<title>{_escape(title)}</title>"
|
|
91
|
-
|
|
92
|
+
# The font CSS lives in its own <style> so the viewer can reuse it for SVG/PNG export
|
|
93
|
+
# instead of embedding the fonts a second time.
|
|
94
|
+
f'<style id="qdbd-font">{font_css()}</style>'
|
|
95
|
+
f"<style>{_asset('style.css')}</style>{head_extra}</head><body>"
|
|
92
96
|
f"{body}"
|
|
93
97
|
f'<script type="application/json" id="qdbd-data">{_json_for_script(data)}</script>'
|
|
94
98
|
f"<script>{_asset('render.js')}</script>"
|
|
@@ -60,7 +60,7 @@ def _column_json(col: Column) -> dict:
|
|
|
60
60
|
name_w = text_width(col.name, s["rowSize"], bold)
|
|
61
61
|
bs = [{"t": b, "w": round(text_width(b, s["badgeSize"]) + 2 * s["badgePad"], 2)} for b in badges(col)]
|
|
62
62
|
type_w = text_width(col.type or "", s["rowSize"])
|
|
63
|
-
|
|
63
|
+
out = {
|
|
64
64
|
"id": col.id,
|
|
65
65
|
"name": col.name,
|
|
66
66
|
"type": col.type or "",
|
|
@@ -75,12 +75,16 @@ def _column_json(col: Column) -> dict:
|
|
|
75
75
|
"typeW": type_w,
|
|
76
76
|
"badges": bs,
|
|
77
77
|
}
|
|
78
|
+
if col.comments:
|
|
79
|
+
out["doc"] = "\n".join(col.comments)
|
|
80
|
+
return out
|
|
78
81
|
|
|
79
82
|
|
|
80
83
|
def table_json(table: Table) -> dict:
|
|
81
84
|
s = STYLE
|
|
82
85
|
cols = [_column_json(c) for c in table.columns.values() if not c.virtual]
|
|
83
|
-
|
|
86
|
+
name_w = text_width(table.name, s["headerSize"])
|
|
87
|
+
w = name_w + 2 * s["padX"]
|
|
84
88
|
for c in cols:
|
|
85
89
|
row = c["nameW"] + c["typeW"] + 2 * s["padX"] + s["gap"]
|
|
86
90
|
row += sum(b["w"] + s["badgeGap"] for b in c["badges"])
|
|
@@ -89,7 +93,10 @@ def table_json(table: Table) -> dict:
|
|
|
89
93
|
w = max(w, row)
|
|
90
94
|
w = max(round(w), s["minW"])
|
|
91
95
|
h = s["headerH"] + len(cols) * s["rowH"] + s["bottomPad"]
|
|
92
|
-
|
|
96
|
+
out = {"id": table.id, "name": table.name, "aliases": table.aliases, "w": w, "h": h, "nameW": name_w, "cols": cols}
|
|
97
|
+
if table.comments:
|
|
98
|
+
out["doc"] = "\n".join(table.comments)
|
|
99
|
+
return out
|
|
93
100
|
|
|
94
101
|
|
|
95
102
|
def model_json(schema: Schema) -> dict:
|
|
@@ -178,13 +178,30 @@ def _handler(state: State, title: str):
|
|
|
178
178
|
return Handler
|
|
179
179
|
|
|
180
180
|
|
|
181
|
-
def
|
|
181
|
+
def make_server(project: Project, port: int = 8765) -> tuple[ThreadingHTTPServer, State]:
|
|
182
|
+
"""Bind the HTTP server (port 0 picks a free port) without starting it."""
|
|
182
183
|
state = State(project)
|
|
183
|
-
for d in state.diagnostics:
|
|
184
|
-
print(d["text"], file=sys.stderr)
|
|
185
184
|
title = project.inputs[0].stem if len(project.inputs) == 1 else "diagram"
|
|
186
185
|
server = ThreadingHTTPServer(("127.0.0.1", port), _handler(state, title))
|
|
187
186
|
server.daemon_threads = True
|
|
187
|
+
return server, state
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def poll_once(state: State) -> bool:
|
|
191
|
+
"""Reload if any schema file changed; returns True when a reload happened."""
|
|
192
|
+
if not state.changed():
|
|
193
|
+
return False
|
|
194
|
+
try:
|
|
195
|
+
state.reload()
|
|
196
|
+
except Exception as e: # keep serving on unexpected errors
|
|
197
|
+
print(f"[qdbd] reload failed: {e}", file=sys.stderr, flush=True)
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def serve(project: Project, port: int = 8765, open_browser: bool = True) -> int:
|
|
202
|
+
server, state = make_server(project, port)
|
|
203
|
+
for d in state.diagnostics:
|
|
204
|
+
print(d["text"], file=sys.stderr)
|
|
188
205
|
url = f"http://127.0.0.1:{server.server_address[1]}/"
|
|
189
206
|
print(f"[qdbd] serving {len(state.files)} file(s) at {url} (layout: {display_path(project.sidecar)})")
|
|
190
207
|
print("[qdbd] Ctrl+C to stop", flush=True)
|
|
@@ -192,11 +209,7 @@ def serve(project: Project, port: int = 8765, open_browser: bool = True) -> int:
|
|
|
192
209
|
def watch() -> None:
|
|
193
210
|
while True:
|
|
194
211
|
time.sleep(0.3)
|
|
195
|
-
|
|
196
|
-
try:
|
|
197
|
-
state.reload()
|
|
198
|
-
except Exception as e: # keep serving on unexpected errors
|
|
199
|
-
print(f"[qdbd] reload failed: {e}", file=sys.stderr, flush=True)
|
|
212
|
+
poll_once(state)
|
|
200
213
|
|
|
201
214
|
threading.Thread(target=watch, daemon=True).start()
|
|
202
215
|
if open_browser:
|
|
@@ -8,6 +8,7 @@ import pytest
|
|
|
8
8
|
|
|
9
9
|
from quickdbd_cli import export, layout
|
|
10
10
|
from quickdbd_cli.cli import main
|
|
11
|
+
from quickdbd_cli.html import render_page
|
|
11
12
|
from quickdbd_cli.measure import model_json, text_width
|
|
12
13
|
from quickdbd_cli.parser import compile_text
|
|
13
14
|
from quickdbd_cli.project import Project
|
|
@@ -138,6 +139,7 @@ def test_check_strict_warnings(tmp_path):
|
|
|
138
139
|
def test_build_is_deterministic_and_does_not_write_sidecar(tmp_path):
|
|
139
140
|
work = tmp_path / "s"
|
|
140
141
|
shutil.copytree(SYNTH, work)
|
|
142
|
+
(work / "qdbd.layout.json").unlink(missing_ok=True)
|
|
141
143
|
a, b = tmp_path / "a.html", tmp_path / "b.html"
|
|
142
144
|
assert main(["build", str(work), "-o", str(a)]) == 0
|
|
143
145
|
assert main(["build", str(work), "-o", str(b)]) == 0
|
|
@@ -159,6 +161,24 @@ def test_layout_command_writes_sidecar_and_reset(tmp_path):
|
|
|
159
161
|
assert json.loads(side.read_text())["tables"]["Customer"] != {"x": 9999, "y": 9999}
|
|
160
162
|
|
|
161
163
|
|
|
164
|
+
def test_build_uses_but_never_rewrites_committed_sidecar(tmp_path):
|
|
165
|
+
work = tmp_path / "s"
|
|
166
|
+
shutil.copytree(SYNTH, work)
|
|
167
|
+
side = work / "qdbd.layout.json"
|
|
168
|
+
before = side.read_bytes()
|
|
169
|
+
assert main(["build", str(work), "-o", str(tmp_path / "d.html")]) == 0
|
|
170
|
+
assert side.read_bytes() == before
|
|
171
|
+
html = (tmp_path / "d.html").read_text()
|
|
172
|
+
customer = layout.read_sidecar(side)["Customer"]
|
|
173
|
+
assert f'"Customer":[{customer[0]},{customer[1]}]' in html
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_html_embeds_font_once():
|
|
177
|
+
html = render_page(model_json(compile_text(SMALL)), {"Customer": [0, 0], "Order": [300, 0]}, "t")
|
|
178
|
+
assert html.count("@font-face") == 2 # regular + bold, once each
|
|
179
|
+
assert 'id="qdbd-font"' in html
|
|
180
|
+
|
|
181
|
+
|
|
162
182
|
def test_missing_path(capsys):
|
|
163
183
|
assert main(["check", "nope/does-not-exist.txt"]) == 2
|
|
164
184
|
|
|
@@ -166,3 +186,34 @@ def test_missing_path(capsys):
|
|
|
166
186
|
@pytest.mark.parametrize("bad_suffix", ["out.jpg"])
|
|
167
187
|
def test_export_rejects_unknown_format(tmp_path, bad_suffix):
|
|
168
188
|
assert main(["export", str(SAMPLE), "-o", str(tmp_path / bad_suffix)]) == 2
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
DOCS = """# Customers who placed at least one order
|
|
192
|
+
Customer # one row per person
|
|
193
|
+
-
|
|
194
|
+
CustomerId PK int
|
|
195
|
+
# Contact address
|
|
196
|
+
Email string # verified
|
|
197
|
+
Name string
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_doc_comments_in_model():
|
|
202
|
+
m = model_json(compile_text(DOCS))
|
|
203
|
+
t = m["tables"][0]
|
|
204
|
+
assert t["doc"] == "Customers who placed at least one order\none row per person"
|
|
205
|
+
cols = {c["id"]: c for c in t["cols"]}
|
|
206
|
+
assert cols["Email"]["doc"] == "Contact address\nverified"
|
|
207
|
+
assert "doc" not in cols["Name"]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_doc_marks_only_in_viewer_not_exports():
|
|
211
|
+
from quickdbd_cli.jsrt import runtime
|
|
212
|
+
|
|
213
|
+
m = model_json(compile_text(DOCS))
|
|
214
|
+
pos = {"Customer": [0, 0]}
|
|
215
|
+
rt = runtime()
|
|
216
|
+
rt.load("render.js")
|
|
217
|
+
viewer = rt.call("qdbd.renderInner", m, pos, "full", {"docMarks": True})
|
|
218
|
+
assert viewer.count('class="dm"') == 2 # table header + Email
|
|
219
|
+
assert 'class="dm"' not in export.render_svg(m, pos, embed_font=False)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""`qdbd serve` HTTP endpoints, driven without a browser."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from quickdbd_cli import layout
|
|
13
|
+
from quickdbd_cli.project import Project, display_path
|
|
14
|
+
from quickdbd_cli.serve import make_server, poll_once
|
|
15
|
+
|
|
16
|
+
SCHEMA = """Customer
|
|
17
|
+
-
|
|
18
|
+
CustomerId PK int
|
|
19
|
+
Name string
|
|
20
|
+
|
|
21
|
+
Order
|
|
22
|
+
-
|
|
23
|
+
OrderId PK int
|
|
24
|
+
CustomerId int FK >- Customer.CustomerId
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def srv(tmp_path):
|
|
30
|
+
f = tmp_path / "shop.txt"
|
|
31
|
+
f.write_text(SCHEMA)
|
|
32
|
+
server, state = make_server(Project.open([f]), port=0)
|
|
33
|
+
t = threading.Thread(target=server.serve_forever, daemon=True)
|
|
34
|
+
t.start()
|
|
35
|
+
base = f"http://127.0.0.1:{server.server_address[1]}"
|
|
36
|
+
yield base, state, f
|
|
37
|
+
server.shutdown()
|
|
38
|
+
server.server_close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def call(base, path, method="GET", body=None):
|
|
42
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
43
|
+
req = urllib.request.Request(base + path, data=data, method=method, headers={"Content-Type": "application/json"})
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
46
|
+
raw = r.read()
|
|
47
|
+
return r.status, (json.loads(raw) if r.headers.get_content_type() == "application/json" else raw)
|
|
48
|
+
except urllib.error.HTTPError as e:
|
|
49
|
+
return e.code, json.loads(e.read())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bump_mtime(path):
|
|
53
|
+
st = path.stat()
|
|
54
|
+
os.utime(path, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000_000))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_page_and_state(srv):
|
|
58
|
+
base, _, f = srv
|
|
59
|
+
code, page = call(base, "/")
|
|
60
|
+
assert code == 200 and b'id="side"' in page and b'"serve":true' in page
|
|
61
|
+
code, st = call(base, "/state")
|
|
62
|
+
assert code == 200
|
|
63
|
+
assert [t["name"] for t in st["model"]["tables"]] == ["Customer", "Order"]
|
|
64
|
+
assert st["files"] == [{"path": display_path(f), "text": SCHEMA, "mtime": st["files"][0]["mtime"]}]
|
|
65
|
+
assert isinstance(st["files"][0]["mtime"], str) # ns mtimes overflow JS numbers
|
|
66
|
+
assert st["diagnostics"] == []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_initial_layout_written_to_sidecar(srv):
|
|
70
|
+
_, state, f = srv
|
|
71
|
+
side = layout.read_sidecar(f.with_name("shop.layout.json"))
|
|
72
|
+
assert set(side) == {"Customer", "Order"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_put_file_saves_and_returns_new_mtime(srv):
|
|
76
|
+
base, _, f = srv
|
|
77
|
+
_, st = call(base, "/state")
|
|
78
|
+
path, mtime = st["files"][0]["path"], st["files"][0]["mtime"]
|
|
79
|
+
new = SCHEMA + "\nProduct\n-\nProductId PK int\n"
|
|
80
|
+
code, res = call(base, "/file", "PUT", {"path": path, "text": new, "mtime": mtime})
|
|
81
|
+
assert code == 200 and res["mtime"] != mtime
|
|
82
|
+
assert f.read_text() == new
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_put_file_refuses_stale_write(srv):
|
|
86
|
+
base, _, f = srv
|
|
87
|
+
_, st = call(base, "/state")
|
|
88
|
+
path, mtime = st["files"][0]["path"], st["files"][0]["mtime"]
|
|
89
|
+
f.write_text(SCHEMA + "\n# edited elsewhere\n")
|
|
90
|
+
bump_mtime(f)
|
|
91
|
+
code, res = call(base, "/file", "PUT", {"path": path, "text": "clobber", "mtime": mtime})
|
|
92
|
+
assert code == 409
|
|
93
|
+
assert "# edited elsewhere" in res["text"]
|
|
94
|
+
assert "# edited elsewhere" in f.read_text() # disk untouched
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_put_unknown_file(srv):
|
|
98
|
+
base, _, _ = srv
|
|
99
|
+
code, _ = call(base, "/file", "PUT", {"path": "nope.txt", "text": "x", "mtime": None})
|
|
100
|
+
assert code == 404
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_post_layout_updates_sidecar(srv):
|
|
104
|
+
base, state, f = srv
|
|
105
|
+
code, _ = call(base, "/layout", "POST", {"Customer": [1234, 567], "Unknown": [1, 1]})
|
|
106
|
+
assert code == 200
|
|
107
|
+
side = layout.read_sidecar(f.with_name("shop.layout.json"))
|
|
108
|
+
assert side["Customer"] == (1234, 567)
|
|
109
|
+
assert "Unknown" not in side
|
|
110
|
+
_, st = call(base, "/state")
|
|
111
|
+
assert st["positions"]["Customer"] == [1234, 567]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_external_edit_reloads_and_places_new_table(srv):
|
|
115
|
+
base, state, f = srv
|
|
116
|
+
_, before = call(base, "/state")
|
|
117
|
+
f.write_text(SCHEMA + "\nLine\n-\nLineId PK int\nOrderId int FK >- Order.OrderId\n")
|
|
118
|
+
bump_mtime(f)
|
|
119
|
+
assert poll_once(state)
|
|
120
|
+
_, after = call(base, "/state")
|
|
121
|
+
assert after["version"] > before["version"]
|
|
122
|
+
assert "Line" in after["positions"]
|
|
123
|
+
# existing tables did not move
|
|
124
|
+
for t in ("Customer", "Order"):
|
|
125
|
+
assert after["positions"][t] == before["positions"][t]
|
|
126
|
+
assert not poll_once(state) # nothing changed since
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_errors_keep_last_good_model(srv):
|
|
130
|
+
base, state, f = srv
|
|
131
|
+
f.write_text(SCHEMA + "\nBad\n-\nX int FK >- Nope.Id\n")
|
|
132
|
+
bump_mtime(f)
|
|
133
|
+
poll_once(state)
|
|
134
|
+
_, st = call(base, "/state")
|
|
135
|
+
assert [d["severity"] for d in st["diagnostics"]] == ["error"]
|
|
136
|
+
assert "Invalid relationship, the table 'Nope' does not exist" in st["diagnostics"][0]["text"]
|
|
137
|
+
assert [t["name"] for t in st["model"]["tables"]] == ["Customer", "Order"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_events_stream_announces_versions(srv):
|
|
141
|
+
base, state, f = srv
|
|
142
|
+
got = []
|
|
143
|
+
|
|
144
|
+
def listen():
|
|
145
|
+
with urllib.request.urlopen(base + "/events", timeout=10) as r:
|
|
146
|
+
for line in r:
|
|
147
|
+
line = line.decode().strip()
|
|
148
|
+
if line.startswith("data:"):
|
|
149
|
+
got.append(int(line[5:]))
|
|
150
|
+
if len(got) == 2:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
t = threading.Thread(target=listen, daemon=True)
|
|
154
|
+
t.start()
|
|
155
|
+
time.sleep(0.3)
|
|
156
|
+
f.write_text(SCHEMA + "\n# touch\n")
|
|
157
|
+
bump_mtime(f)
|
|
158
|
+
poll_once(state)
|
|
159
|
+
t.join(timeout=10)
|
|
160
|
+
assert len(got) == 2 and got[1] > got[0]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|