quickdbd-cli 0.1.8__tar.gz → 0.1.9__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.8 → quickdbd_cli-0.1.9}/PKG-INFO +26 -6
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/README.md +25 -5
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/pyproject.toml +1 -1
- quickdbd_cli-0.1.9/src/quickdbd_cli/__init__.py +1 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/cli.py +50 -3
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/html.py +22 -6
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/serve.py +27 -11
- quickdbd_cli-0.1.9/src/quickdbd_cli/share.py +109 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_serve.py +36 -0
- quickdbd_cli-0.1.9/tests/test_share.py +116 -0
- quickdbd_cli-0.1.8/src/quickdbd_cli/__init__.py +0 -1
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/.gitignore +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/LICENSE +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/NOTICE +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/quickdbd-sample.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/finance.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/hr.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/inventory.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/marketing.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/qdbd.layout.json +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/sales.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/scripts/gen_font_metrics.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/scripts/gen_schema.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/scripts/release.sh +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/__main__.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/editor.js +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/font-metrics.json +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/render.js +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/style.css +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/viewer.js +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/dbt.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/diff.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/export.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/jsrt.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/layout.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/measure.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/model.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/parser.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/project.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/tags.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/LICENSE +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/README.md +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/catalog.json +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/manifest.json +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_dbt.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_diff.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_parser.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_path.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_pipeline.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_select.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_tags.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.9
|
|
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
|
|
@@ -121,7 +121,7 @@ A browser tab opens with the diagram and an editor pane.
|
|
|
121
121
|
qdbd build schema.txt -o schema.html
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
`schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)).
|
|
124
|
+
`schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)). To send a link instead of the file, see [4.4](#44-share-a-link).
|
|
125
125
|
|
|
126
126
|
### 2.5 Export an image
|
|
127
127
|
|
|
@@ -290,6 +290,25 @@ qdbd path schema/ --from Invoice --to Campaign
|
|
|
290
290
|
|
|
291
291
|

|
|
292
292
|
|
|
293
|
+
### 4.4 Share a link
|
|
294
|
+
|
|
295
|
+
`qdbd serve`'s address only works on your machine. To send someone a **link**:
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
qdbd share schema.txt # needs the GitHub CLI: brew install gh && gh auth login
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```text
|
|
302
|
+
Shared 12 tables (secret gist https://gist.github.com/you/1a2b3c…)
|
|
303
|
+
Link: https://gistpreview.github.io/?1a2b3c…
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
- The link opens the full interactive page. Running `qdbd share` again updates the same link.
|
|
307
|
+
- Gists are secret (link-only) by default; `--public` lists it.
|
|
308
|
+
- For a **live** view while you edit, run `qdbd serve schema.txt --read-only` (no editor, nothing written to disk) behind a tunnel such as `cloudflared tunnel --url http://127.0.0.1:8765`. Never tunnel a plain `qdbd serve`: viewers could edit your files.
|
|
309
|
+
|
|
310
|
+
More: [Sharing a Link](https://markphamm.github.io/quickdbd-cli/sharing/).
|
|
311
|
+
|
|
293
312
|
## 5. dbt projects
|
|
294
313
|
|
|
295
314
|
### 5.1 Generate the schema from dbt
|
|
@@ -368,7 +387,7 @@ jobs:
|
|
|
368
387
|
permissions: { contents: read, pull-requests: write }
|
|
369
388
|
steps:
|
|
370
389
|
- uses: actions/checkout@v4
|
|
371
|
-
- uses: MarkPhamm/quickdbd-cli@v0.1.
|
|
390
|
+
- uses: MarkPhamm/quickdbd-cli@v0.1.9
|
|
372
391
|
with:
|
|
373
392
|
path: erd/ # schema file(s) or folder(s)
|
|
374
393
|
output: site/index.html
|
|
@@ -391,7 +410,7 @@ Run your dbt setup first, then let the action generate the schema:
|
|
|
391
410
|
|
|
392
411
|
```yaml
|
|
393
412
|
# … dbt deps && dbt build && dbt docs generate
|
|
394
|
-
- uses: MarkPhamm/quickdbd-cli@v0.1.
|
|
413
|
+
- uses: MarkPhamm/quickdbd-cli@v0.1.9
|
|
395
414
|
with:
|
|
396
415
|
dbt-manifest: target/
|
|
397
416
|
dbt-args: --infer-fks
|
|
@@ -406,7 +425,7 @@ Commit `erd/` so pull requests can be compared with `main`. Set `dbt-check: true
|
|
|
406
425
|
```yaml
|
|
407
426
|
repos:
|
|
408
427
|
- repo: https://github.com/MarkPhamm/quickdbd-cli
|
|
409
|
-
rev: v0.1.
|
|
428
|
+
rev: v0.1.9
|
|
410
429
|
hooks:
|
|
411
430
|
- id: qdbd-check
|
|
412
431
|
files: ^erd/.*\.txt$
|
|
@@ -431,8 +450,9 @@ Builds are deterministic (no timestamps or randomness), so the output only chang
|
|
|
431
450
|
|
|
432
451
|
```bash
|
|
433
452
|
qdbd check PATH... [--strict] # validate
|
|
434
|
-
qdbd serve PATH... [--port 8765]
|
|
453
|
+
qdbd serve PATH... [--port 8765] [--read-only] # live editor + drag to arrange
|
|
435
454
|
qdbd build PATH... -o diagram.html # interactive HTML
|
|
455
|
+
qdbd share PATH... [--public] # publish as a gist, print a link
|
|
436
456
|
qdbd export PATH... -o out.png|.svg|.drawio [--focus T --depth 1]
|
|
437
457
|
qdbd layout PATH... [--reset] # (re)write the layout file
|
|
438
458
|
qdbd path PATH... --from A --to B # shortest join path + SQL JOIN
|
|
@@ -93,7 +93,7 @@ A browser tab opens with the diagram and an editor pane.
|
|
|
93
93
|
qdbd build schema.txt -o schema.html
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
`schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)).
|
|
96
|
+
`schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)). To send a link instead of the file, see [4.4](#44-share-a-link).
|
|
97
97
|
|
|
98
98
|
### 2.5 Export an image
|
|
99
99
|
|
|
@@ -262,6 +262,25 @@ qdbd path schema/ --from Invoice --to Campaign
|
|
|
262
262
|
|
|
263
263
|

|
|
264
264
|
|
|
265
|
+
### 4.4 Share a link
|
|
266
|
+
|
|
267
|
+
`qdbd serve`'s address only works on your machine. To send someone a **link**:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
qdbd share schema.txt # needs the GitHub CLI: brew install gh && gh auth login
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
```text
|
|
274
|
+
Shared 12 tables (secret gist https://gist.github.com/you/1a2b3c…)
|
|
275
|
+
Link: https://gistpreview.github.io/?1a2b3c…
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- The link opens the full interactive page. Running `qdbd share` again updates the same link.
|
|
279
|
+
- Gists are secret (link-only) by default; `--public` lists it.
|
|
280
|
+
- For a **live** view while you edit, run `qdbd serve schema.txt --read-only` (no editor, nothing written to disk) behind a tunnel such as `cloudflared tunnel --url http://127.0.0.1:8765`. Never tunnel a plain `qdbd serve`: viewers could edit your files.
|
|
281
|
+
|
|
282
|
+
More: [Sharing a Link](https://markphamm.github.io/quickdbd-cli/sharing/).
|
|
283
|
+
|
|
265
284
|
## 5. dbt projects
|
|
266
285
|
|
|
267
286
|
### 5.1 Generate the schema from dbt
|
|
@@ -340,7 +359,7 @@ jobs:
|
|
|
340
359
|
permissions: { contents: read, pull-requests: write }
|
|
341
360
|
steps:
|
|
342
361
|
- uses: actions/checkout@v4
|
|
343
|
-
- uses: MarkPhamm/quickdbd-cli@v0.1.
|
|
362
|
+
- uses: MarkPhamm/quickdbd-cli@v0.1.9
|
|
344
363
|
with:
|
|
345
364
|
path: erd/ # schema file(s) or folder(s)
|
|
346
365
|
output: site/index.html
|
|
@@ -363,7 +382,7 @@ Run your dbt setup first, then let the action generate the schema:
|
|
|
363
382
|
|
|
364
383
|
```yaml
|
|
365
384
|
# … dbt deps && dbt build && dbt docs generate
|
|
366
|
-
- uses: MarkPhamm/quickdbd-cli@v0.1.
|
|
385
|
+
- uses: MarkPhamm/quickdbd-cli@v0.1.9
|
|
367
386
|
with:
|
|
368
387
|
dbt-manifest: target/
|
|
369
388
|
dbt-args: --infer-fks
|
|
@@ -378,7 +397,7 @@ Commit `erd/` so pull requests can be compared with `main`. Set `dbt-check: true
|
|
|
378
397
|
```yaml
|
|
379
398
|
repos:
|
|
380
399
|
- repo: https://github.com/MarkPhamm/quickdbd-cli
|
|
381
|
-
rev: v0.1.
|
|
400
|
+
rev: v0.1.9
|
|
382
401
|
hooks:
|
|
383
402
|
- id: qdbd-check
|
|
384
403
|
files: ^erd/.*\.txt$
|
|
@@ -403,8 +422,9 @@ Builds are deterministic (no timestamps or randomness), so the output only chang
|
|
|
403
422
|
|
|
404
423
|
```bash
|
|
405
424
|
qdbd check PATH... [--strict] # validate
|
|
406
|
-
qdbd serve PATH... [--port 8765]
|
|
425
|
+
qdbd serve PATH... [--port 8765] [--read-only] # live editor + drag to arrange
|
|
407
426
|
qdbd build PATH... -o diagram.html # interactive HTML
|
|
427
|
+
qdbd share PATH... [--public] # publish as a gist, print a link
|
|
408
428
|
qdbd export PATH... -o out.png|.svg|.drawio [--focus T --depth 1]
|
|
409
429
|
qdbd layout PATH... [--reset] # (re)write the layout file
|
|
410
430
|
qdbd path PATH... --from A --to B # shortest join path + SQL JOIN
|
|
@@ -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.9"
|
|
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 @@
|
|
|
1
|
+
__version__ = "0.1.9"
|
|
@@ -47,7 +47,8 @@ def cmd_check(args) -> int:
|
|
|
47
47
|
return 0 if ok else 1
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
def
|
|
50
|
+
def _built_page(args) -> tuple[str, dict] | int:
|
|
51
|
+
"""Compile, select and lay out like `build`; returns (html, model) or an exit code."""
|
|
51
52
|
from .html import render_page
|
|
52
53
|
|
|
53
54
|
project = _open(args)
|
|
@@ -80,13 +81,41 @@ def cmd_build(args) -> int:
|
|
|
80
81
|
print(f"error: {e}", file=sys.stderr)
|
|
81
82
|
return 2
|
|
82
83
|
raise
|
|
84
|
+
return render_page(model, pos, _title(project)), model
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_build(args) -> int:
|
|
88
|
+
res = _built_page(args)
|
|
89
|
+
if isinstance(res, int):
|
|
90
|
+
return res
|
|
91
|
+
page, model = res
|
|
83
92
|
out = Path(args.output)
|
|
84
93
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
-
out.write_text(
|
|
94
|
+
out.write_text(page, encoding="utf-8")
|
|
86
95
|
print(f"Wrote {out} ({len(model['tables'])} tables, {len(model['rels'])} relationships)")
|
|
87
96
|
return 0
|
|
88
97
|
|
|
89
98
|
|
|
99
|
+
def cmd_share(args) -> int:
|
|
100
|
+
from . import share
|
|
101
|
+
|
|
102
|
+
res = _built_page(args)
|
|
103
|
+
if isinstance(res, int):
|
|
104
|
+
return res
|
|
105
|
+
page, model = res
|
|
106
|
+
key = share.share_key(args.paths, selector_expr(args.select, args.exclude))
|
|
107
|
+
try:
|
|
108
|
+
r = share.publish(page, _title(_open(args)), key, public=args.public, new=args.new)
|
|
109
|
+
except share.ShareError as e:
|
|
110
|
+
print(f"error: {e}", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
verb = "Updated" if r.updated else "Shared"
|
|
113
|
+
print(f"{verb} {len(model['tables'])} tables ({'public' if r.public else 'secret'} gist {r.gist_url})")
|
|
114
|
+
print(f"Link: {r.link}")
|
|
115
|
+
print(f" (if that doesn't load: {r.alt_link})")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
90
119
|
def cmd_export(args) -> int:
|
|
91
120
|
from . import export
|
|
92
121
|
|
|
@@ -289,7 +318,7 @@ def cmd_serve(args) -> int:
|
|
|
289
318
|
from .serve import serve
|
|
290
319
|
|
|
291
320
|
project = _open(args)
|
|
292
|
-
return serve(project, port=args.port, open_browser=not args.no_open)
|
|
321
|
+
return serve(project, port=args.port, open_browser=not args.no_open, read_only=args.read_only)
|
|
293
322
|
|
|
294
323
|
|
|
295
324
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -328,6 +357,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
328
357
|
sp.add_argument("--strict", action="store_true")
|
|
329
358
|
sp.set_defaults(func=cmd_build)
|
|
330
359
|
|
|
360
|
+
sp = sub.add_parser("share", help="publish the diagram as a GitHub gist and print a link (needs the gh CLI)")
|
|
361
|
+
paths(sp)
|
|
362
|
+
selectors(sp)
|
|
363
|
+
sp.add_argument(
|
|
364
|
+
"--diff-base", metavar="GIT_REF", help="colour tables/columns changed since this git ref (e.g. origin/main)"
|
|
365
|
+
)
|
|
366
|
+
sp.add_argument("--by-group", action="store_true", help=BY_GROUP_HELP)
|
|
367
|
+
sp.add_argument("--layout", help="layout sidecar file (default: next to the schema)")
|
|
368
|
+
sp.add_argument("--strict", action="store_true")
|
|
369
|
+
sp.add_argument("--public", action="store_true", help="list the gist publicly (default: secret, link-only)")
|
|
370
|
+
sp.add_argument("--new", action="store_true", help="make a new gist instead of updating the last one")
|
|
371
|
+
sp.set_defaults(func=cmd_share)
|
|
372
|
+
|
|
331
373
|
sp = sub.add_parser("export", help="write a PNG, SVG or draw.io file")
|
|
332
374
|
paths(sp)
|
|
333
375
|
sp.add_argument("-o", "--output", required=True, help="out.png, out.svg or out.drawio")
|
|
@@ -398,6 +440,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
398
440
|
sp.add_argument("--port", type=int, default=8765)
|
|
399
441
|
sp.add_argument("--layout")
|
|
400
442
|
sp.add_argument("--no-open", action="store_true", help="don't open a browser")
|
|
443
|
+
sp.add_argument(
|
|
444
|
+
"--read-only",
|
|
445
|
+
action="store_true",
|
|
446
|
+
help="live view only: no editor, nothing saved; safe to share through a tunnel",
|
|
447
|
+
)
|
|
401
448
|
sp.set_defaults(func=cmd_serve)
|
|
402
449
|
return p
|
|
403
450
|
|
|
@@ -92,13 +92,27 @@ FILTER_HELP = (
|
|
|
92
92
|
)
|
|
93
93
|
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
# Read-only live view: refetch the model whenever the server's version changes. Relative URLs, so it
|
|
96
|
+
# also works behind a tunnel or a path prefix.
|
|
97
|
+
LIVE_JS = (
|
|
98
|
+
"(function(){var v=null;new EventSource('events').onmessage=function(e){"
|
|
99
|
+
"if(v!==null&&e.data!==v)fetch('state').then(function(r){return r.json();})"
|
|
100
|
+
".then(function(s){window.qdbdViewer.setData(s.model,s.positions);});v=e.data;};})();"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def render_page(model: dict, positions: dict, title: str, serve: bool = False, live: bool = False) -> str:
|
|
105
|
+
"""The self-contained page. ``serve``: editor pane and saved dragging. ``live``: read-only, but reloads
|
|
106
|
+
when the server's schema changes (``qdbd serve --read-only``)."""
|
|
96
107
|
data = {"model": model, "positions": positions, "serve": serve, "title": title}
|
|
97
|
-
|
|
98
|
-
"Drag tables to arrange them (saved). Click a table for details. Esc clears."
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
if serve:
|
|
109
|
+
hint = "Drag tables to arrange them (saved). Click a table for details. Esc clears."
|
|
110
|
+
elif live:
|
|
111
|
+
hint = "Live, read-only view: updates when the schema changes. Drag tables to rearrange (not saved)."
|
|
112
|
+
else:
|
|
113
|
+
hint = (
|
|
114
|
+
"Drag tables to rearrange (not saved). Click a table for details. Scroll to zoom, drag empty space to pan."
|
|
115
|
+
)
|
|
102
116
|
body = BODY.format(side=SIDE if serve else "", count=len(model["tables"]), hint=hint, filter_help=FILTER_HELP)
|
|
103
117
|
head_extra = ""
|
|
104
118
|
scripts = ""
|
|
@@ -109,6 +123,8 @@ def render_page(model: dict, positions: dict, title: str, serve: bool = False) -
|
|
|
109
123
|
f'<script src="{CODEMIRROR}/addon/mode/simple.min.js"></script>'
|
|
110
124
|
f"<script>{_asset('editor.js')}</script>"
|
|
111
125
|
)
|
|
126
|
+
elif live:
|
|
127
|
+
scripts = f"<script>{LIVE_JS}</script>"
|
|
112
128
|
return (
|
|
113
129
|
'<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
|
|
114
130
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Watches the schema files (mtime polling), recompiles on change and pushes a reload event to the
|
|
4
4
|
page over Server-Sent Events. Dragged table positions are saved to the layout sidecar; the
|
|
5
5
|
browser editor pane writes the .txt files, refusing to overwrite a file changed on disk since
|
|
6
|
-
the browser last loaded it.
|
|
6
|
+
the browser last loaded it. With ``read_only`` the page is the plain viewer plus live reload, and
|
|
7
|
+
the server never writes anything.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -22,8 +23,9 @@ from .project import Project, discover, display_path
|
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
class State:
|
|
25
|
-
def __init__(self, project: Project):
|
|
26
|
+
def __init__(self, project: Project, read_only: bool = False):
|
|
26
27
|
self.project = project
|
|
28
|
+
self.read_only = read_only # never write to disk: no editor saves, no layout file
|
|
27
29
|
self.lock = threading.Condition()
|
|
28
30
|
self.version = 0
|
|
29
31
|
self.model: dict = {"style": {}, "tables": [], "rels": [], "zoom": None, "position": None}
|
|
@@ -61,7 +63,8 @@ class State:
|
|
|
61
63
|
pos = layout.resolve_positions(model, base, schema.layout)
|
|
62
64
|
self.model = model
|
|
63
65
|
self.positions = {k: [v[0], v[1]] for k, v in pos.items()}
|
|
64
|
-
|
|
66
|
+
if not self.read_only:
|
|
67
|
+
self.project.save_positions(model, self.positions)
|
|
65
68
|
self.version += 1
|
|
66
69
|
self.lock.notify_all()
|
|
67
70
|
if not initial:
|
|
@@ -76,6 +79,8 @@ class State:
|
|
|
76
79
|
|
|
77
80
|
def snapshot(self) -> dict:
|
|
78
81
|
with self.lock:
|
|
82
|
+
if self.read_only: # the model only: no file text, no diagnostics
|
|
83
|
+
return {"version": self.version, "model": self.model, "positions": self.positions}
|
|
79
84
|
files = []
|
|
80
85
|
for f in self.files:
|
|
81
86
|
p = display_path(f)
|
|
@@ -134,7 +139,10 @@ def _handler(state: State, title: str):
|
|
|
134
139
|
def do_GET(self) -> None:
|
|
135
140
|
if self.path == "/":
|
|
136
141
|
with state.lock:
|
|
137
|
-
|
|
142
|
+
if state.read_only:
|
|
143
|
+
page = render_page(state.model, state.positions, title, live=True)
|
|
144
|
+
else:
|
|
145
|
+
page = render_page(state.model, state.positions, title, serve=True)
|
|
138
146
|
self._send(200, page.encode(), "text/html; charset=utf-8")
|
|
139
147
|
elif self.path == "/state":
|
|
140
148
|
self._json(200, state.snapshot())
|
|
@@ -161,14 +169,18 @@ def _handler(state: State, title: str):
|
|
|
161
169
|
self._send(404, b"not found", "text/plain")
|
|
162
170
|
|
|
163
171
|
def do_POST(self) -> None:
|
|
164
|
-
if
|
|
172
|
+
if state.read_only:
|
|
173
|
+
self._json(403, {"error": "read-only"})
|
|
174
|
+
elif self.path == "/layout":
|
|
165
175
|
state.save_layout(self._body())
|
|
166
176
|
self._json(200, {"ok": True})
|
|
167
177
|
else:
|
|
168
178
|
self._send(404, b"not found", "text/plain")
|
|
169
179
|
|
|
170
180
|
def do_PUT(self) -> None:
|
|
171
|
-
if
|
|
181
|
+
if state.read_only:
|
|
182
|
+
self._json(403, {"error": "read-only"})
|
|
183
|
+
elif self.path == "/file":
|
|
172
184
|
b = self._body()
|
|
173
185
|
code, obj = state.write_file(b.get("path", ""), b.get("text", ""), b.get("mtime"))
|
|
174
186
|
self._json(code, obj)
|
|
@@ -178,9 +190,9 @@ def _handler(state: State, title: str):
|
|
|
178
190
|
return Handler
|
|
179
191
|
|
|
180
192
|
|
|
181
|
-
def make_server(project: Project, port: int = 8765) -> tuple[ThreadingHTTPServer, State]:
|
|
193
|
+
def make_server(project: Project, port: int = 8765, read_only: bool = False) -> tuple[ThreadingHTTPServer, State]:
|
|
182
194
|
"""Bind the HTTP server (port 0 picks a free port) without starting it."""
|
|
183
|
-
state = State(project)
|
|
195
|
+
state = State(project, read_only=read_only)
|
|
184
196
|
title = project.inputs[0].stem if len(project.inputs) == 1 else "diagram"
|
|
185
197
|
server = ThreadingHTTPServer(("127.0.0.1", port), _handler(state, title))
|
|
186
198
|
server.daemon_threads = True
|
|
@@ -198,12 +210,16 @@ def poll_once(state: State) -> bool:
|
|
|
198
210
|
return True
|
|
199
211
|
|
|
200
212
|
|
|
201
|
-
def serve(project: Project, port: int = 8765, open_browser: bool = True) -> int:
|
|
202
|
-
server, state = make_server(project, port)
|
|
213
|
+
def serve(project: Project, port: int = 8765, open_browser: bool = True, read_only: bool = False) -> int:
|
|
214
|
+
server, state = make_server(project, port, read_only=read_only)
|
|
203
215
|
for d in state.diagnostics:
|
|
204
216
|
print(d["text"], file=sys.stderr)
|
|
205
217
|
url = f"http://127.0.0.1:{server.server_address[1]}/"
|
|
206
|
-
|
|
218
|
+
if read_only:
|
|
219
|
+
print(f"[qdbd] serving {len(state.files)} file(s) read-only at {url}")
|
|
220
|
+
print(f"[qdbd] nothing is written to disk. To share it: cloudflared tunnel --url {url}")
|
|
221
|
+
else:
|
|
222
|
+
print(f"[qdbd] serving {len(state.files)} file(s) at {url} (layout: {display_path(project.sidecar)})")
|
|
207
223
|
print("[qdbd] Ctrl+C to stop", flush=True)
|
|
208
224
|
|
|
209
225
|
def watch() -> None:
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""`qdbd share`: publish the built page as a GitHub gist and return a link to view it.
|
|
2
|
+
|
|
3
|
+
Uses the GitHub CLI (``gh api``), so it inherits the user's login. Gists are secret (unlisted) unless
|
|
4
|
+
``public``. The gist made for each input/selection is remembered in the user's cache directory, so
|
|
5
|
+
sharing again updates the same gist and the link stays the same.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ShareError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Shared:
|
|
24
|
+
link: str # gistpreview: renders the gist's HTML, keeps the #view part of the URL
|
|
25
|
+
alt_link: str # githack: serves the raw file as text/html (after a one-click confirmation page)
|
|
26
|
+
gist_url: str
|
|
27
|
+
public: bool
|
|
28
|
+
updated: bool
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def share_key(paths: list[str], select: str = "") -> str:
|
|
32
|
+
return "|".join(sorted(str(Path(p).resolve()) for p in paths)) + "#" + select
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cache_file() -> Path:
|
|
36
|
+
if os.environ.get("QDBD_CACHE_DIR"):
|
|
37
|
+
base = Path(os.environ["QDBD_CACHE_DIR"])
|
|
38
|
+
else:
|
|
39
|
+
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "qdbd"
|
|
40
|
+
return base / "shares.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load() -> dict:
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(cache_file().read_text(encoding="utf-8"))
|
|
46
|
+
except (FileNotFoundError, ValueError):
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _save(data: dict) -> None:
|
|
51
|
+
f = cache_file()
|
|
52
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
f.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _gh(method: str, endpoint: str, payload: dict) -> dict:
|
|
57
|
+
try:
|
|
58
|
+
r = subprocess.run(
|
|
59
|
+
["gh", "api", "--method", method, endpoint, "--input", "-"],
|
|
60
|
+
input=json.dumps(payload),
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
check=False,
|
|
64
|
+
)
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
raise ShareError(
|
|
67
|
+
"qdbd share needs the GitHub CLI: install it (brew install gh), then run gh auth login"
|
|
68
|
+
) from None
|
|
69
|
+
if r.returncode != 0:
|
|
70
|
+
msg = (r.stderr or r.stdout).strip()
|
|
71
|
+
if "auth login" in msg or "not logged" in msg.lower():
|
|
72
|
+
raise ShareError("not logged in to GitHub: run gh auth login")
|
|
73
|
+
raise ShareError(f"gh api {method} {endpoint} failed: {msg}")
|
|
74
|
+
return json.loads(r.stdout)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _filename(title: str) -> str:
|
|
78
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip("-.") or "diagram"
|
|
79
|
+
return f"{slug}.html"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def publish(page: str, title: str, key: str, public: bool = False, new: bool = False) -> Shared:
|
|
83
|
+
cache = _load()
|
|
84
|
+
entry = None if new else cache.get(key)
|
|
85
|
+
desc = f"qdbd ERD: {title}"
|
|
86
|
+
gist = None
|
|
87
|
+
if entry:
|
|
88
|
+
try:
|
|
89
|
+
gist = _gh(
|
|
90
|
+
"PATCH", f"/gists/{entry['id']}", {"description": desc, "files": {entry["file"]: {"content": page}}}
|
|
91
|
+
)
|
|
92
|
+
except ShareError as e:
|
|
93
|
+
if "404" not in str(e) and "Not Found" not in str(e):
|
|
94
|
+
raise
|
|
95
|
+
entry = None # the gist was deleted: make a new one
|
|
96
|
+
updated = gist is not None
|
|
97
|
+
fname = entry["file"] if entry else _filename(title)
|
|
98
|
+
if gist is None:
|
|
99
|
+
gist = _gh("POST", "/gists", {"description": desc, "public": public, "files": {fname: {"content": page}}})
|
|
100
|
+
owner = (gist.get("owner") or {}).get("login") or ""
|
|
101
|
+
cache[key] = {"id": gist["id"], "owner": owner, "file": fname}
|
|
102
|
+
_save(cache)
|
|
103
|
+
return Shared(
|
|
104
|
+
link=f"https://gistpreview.github.io/?{gist['id']}",
|
|
105
|
+
alt_link=f"https://gist.githack.com/{owner}/{gist['id']}/raw/{fname}",
|
|
106
|
+
gist_url=gist.get("html_url", ""),
|
|
107
|
+
public=bool(gist.get("public")),
|
|
108
|
+
updated=updated,
|
|
109
|
+
)
|
|
@@ -158,3 +158,39 @@ def test_events_stream_announces_versions(srv):
|
|
|
158
158
|
poll_once(state)
|
|
159
159
|
t.join(timeout=10)
|
|
160
160
|
assert len(got) == 2 and got[1] > got[0]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------ --read-only
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.fixture
|
|
167
|
+
def ro(tmp_path):
|
|
168
|
+
f = tmp_path / "shop.txt"
|
|
169
|
+
f.write_text(SCHEMA)
|
|
170
|
+
server, state = make_server(Project.open([f]), port=0, read_only=True)
|
|
171
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
172
|
+
yield f"http://127.0.0.1:{server.server_address[1]}", state, f
|
|
173
|
+
server.shutdown()
|
|
174
|
+
server.server_close()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_read_only_page_has_no_editor_and_reloads_live(ro):
|
|
178
|
+
base, _, _ = ro
|
|
179
|
+
code, page = call(base, "/")
|
|
180
|
+
assert code == 200 and b'id="side"' not in page and b'"serve":false' in page
|
|
181
|
+
assert b"codemirror.min.js" not in page and b"EventSource('events')" in page
|
|
182
|
+
code, st = call(base, "/state")
|
|
183
|
+
assert code == 200 and set(st) == {"version", "model", "positions"} # no file text
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_read_only_refuses_writes_and_never_touches_disk(ro):
|
|
187
|
+
base, state, f = ro
|
|
188
|
+
assert not f.with_name("shop.layout.json").exists()
|
|
189
|
+
assert call(base, "/file", "PUT", {"path": display_path(f), "text": "x", "mtime": None})[0] == 403
|
|
190
|
+
assert call(base, "/layout", "POST", {"Customer": [1, 2]})[0] == 403
|
|
191
|
+
assert f.read_text() == SCHEMA
|
|
192
|
+
f.write_text(SCHEMA + "\nProduct\n-\nProductId PK int\n")
|
|
193
|
+
bump_mtime(f)
|
|
194
|
+
assert poll_once(state)
|
|
195
|
+
assert [t["name"] for t in call(base, "/state")[1]["model"]["tables"]] == ["Customer", "Order", "Product"]
|
|
196
|
+
assert not f.with_name("shop.layout.json").exists()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""`qdbd share`, with `gh api` faked (no network)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from quickdbd_cli import share
|
|
9
|
+
from quickdbd_cli.cli import main
|
|
10
|
+
|
|
11
|
+
SCHEMA = "Customer\n-\nId PK int\n\nOrder\n-\nId PK int\nCustomerId int FK >- Customer.Id\n"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeGh:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.calls = []
|
|
17
|
+
self.gists = {}
|
|
18
|
+
self.fail = None
|
|
19
|
+
|
|
20
|
+
def __call__(self, cmd, input=None, **kw):
|
|
21
|
+
assert cmd[:3] == ["gh", "api", "--method"] and cmd[-2:] == ["--input", "-"]
|
|
22
|
+
method, endpoint, payload = cmd[3], cmd[4], json.loads(input)
|
|
23
|
+
self.calls.append((method, endpoint, payload))
|
|
24
|
+
if self.fail:
|
|
25
|
+
return subprocess.CompletedProcess(cmd, 1, "", self.fail)
|
|
26
|
+
if method == "POST":
|
|
27
|
+
gid = f"g{len(self.gists) + 1}"
|
|
28
|
+
self.gists[gid] = payload
|
|
29
|
+
body = {"id": gid, "html_url": f"https://gist.github.com/me/{gid}", "public": payload["public"]}
|
|
30
|
+
else:
|
|
31
|
+
gid = endpoint.rsplit("/", 1)[1]
|
|
32
|
+
if gid not in self.gists:
|
|
33
|
+
return subprocess.CompletedProcess(cmd, 1, "", "gh: Not Found (HTTP 404)")
|
|
34
|
+
self.gists[gid]["files"].update(payload["files"])
|
|
35
|
+
body = {"id": gid, "html_url": f"https://gist.github.com/me/{gid}", "public": self.gists[gid]["public"]}
|
|
36
|
+
body["owner"] = {"login": "me"}
|
|
37
|
+
return subprocess.CompletedProcess(cmd, 0, json.dumps(body), "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def gh(monkeypatch, tmp_path):
|
|
42
|
+
fake = FakeGh()
|
|
43
|
+
monkeypatch.setattr(share.subprocess, "run", fake)
|
|
44
|
+
monkeypatch.setenv("QDBD_CACHE_DIR", str(tmp_path / "cache"))
|
|
45
|
+
return fake
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def schema(tmp_path):
|
|
50
|
+
f = tmp_path / "shop.txt"
|
|
51
|
+
f.write_text(SCHEMA)
|
|
52
|
+
return f
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_share_creates_a_secret_gist_and_prints_links(gh, schema, capsys):
|
|
56
|
+
assert main(["share", str(schema)]) == 0
|
|
57
|
+
out = capsys.readouterr().out
|
|
58
|
+
method, endpoint, payload = gh.calls[0]
|
|
59
|
+
assert (method, endpoint, payload["public"]) == ("POST", "/gists", False)
|
|
60
|
+
assert list(payload["files"]) == ["shop.html"]
|
|
61
|
+
assert payload["files"]["shop.html"]["content"].startswith("<!doctype html>")
|
|
62
|
+
assert "Shared 2 tables (secret gist https://gist.github.com/me/g1)" in out
|
|
63
|
+
assert "Link: https://gistpreview.github.io/?g1" in out
|
|
64
|
+
assert "https://gist.githack.com/me/g1/raw/shop.html" in out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_sharing_again_updates_the_same_gist(gh, schema, capsys):
|
|
68
|
+
main(["share", str(schema)])
|
|
69
|
+
schema.write_text(SCHEMA + "\nProduct\n-\nId PK int\n")
|
|
70
|
+
assert main(["share", str(schema)]) == 0
|
|
71
|
+
assert [c[0] for c in gh.calls] == ["POST", "PATCH"]
|
|
72
|
+
assert gh.calls[1][1] == "/gists/g1"
|
|
73
|
+
assert "Product" in gh.gists["g1"]["files"]["shop.html"]["content"]
|
|
74
|
+
assert "Updated 3 tables" in capsys.readouterr().out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_new_and_selection_make_separate_gists(gh, schema):
|
|
78
|
+
main(["share", str(schema)])
|
|
79
|
+
main(["share", str(schema), "--new"])
|
|
80
|
+
main(["share", str(schema), "-s", "Order+"])
|
|
81
|
+
assert [c[0] for c in gh.calls] == ["POST", "POST", "POST"]
|
|
82
|
+
main(["share", str(schema), "-s", "Order+"])
|
|
83
|
+
assert gh.calls[-1][:2] == ("PATCH", "/gists/g3")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_deleted_gist_is_recreated(gh, schema):
|
|
87
|
+
main(["share", str(schema)])
|
|
88
|
+
gh.gists.clear()
|
|
89
|
+
assert main(["share", str(schema)]) == 0
|
|
90
|
+
assert [c[0] for c in gh.calls] == ["POST", "PATCH", "POST"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_public_flag(gh, schema, capsys):
|
|
94
|
+
main(["share", str(schema), "--public"])
|
|
95
|
+
assert gh.calls[0][2]["public"] is True
|
|
96
|
+
assert "(public gist" in capsys.readouterr().out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_errors(gh, schema, capsys, monkeypatch):
|
|
100
|
+
gh.fail = "To get started with GitHub CLI, please run: gh auth login"
|
|
101
|
+
assert main(["share", str(schema)]) == 2
|
|
102
|
+
assert "run gh auth login" in capsys.readouterr().err
|
|
103
|
+
|
|
104
|
+
def missing(*a, **k):
|
|
105
|
+
raise FileNotFoundError
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr(share.subprocess, "run", missing)
|
|
108
|
+
assert main(["share", str(schema)]) == 2
|
|
109
|
+
assert "needs the GitHub CLI" in capsys.readouterr().err
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_schema_errors_stop_before_uploading(gh, tmp_path):
|
|
113
|
+
f = tmp_path / "bad.txt"
|
|
114
|
+
f.write_text("A\n-\nId int FK >- Nope.Id\n")
|
|
115
|
+
assert main(["share", str(f)]) == 1
|
|
116
|
+
assert gh.calls == []
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.8"
|
|
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
|
|
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
|