ripple-sql 0.1.2__tar.gz → 0.1.4__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.
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/CHANGELOG.md +17 -1
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/PKG-INFO +1 -1
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/pyproject.toml +1 -1
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/answer_page.py +4 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/cte_tracing.py +2 -0
- ripple_sql-0.1.4/src/ripple/engine/generators.py +61 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/statement.py +2 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/home.py +91 -14
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/render.py +45 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/server.py +0 -1
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/static/answer.css +73 -1
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/static/answer.html +92 -24
- ripple_sql-0.1.4/src/ripple/static/explore.js +314 -0
- ripple_sql-0.1.4/src/ripple/static/find.js +72 -0
- ripple_sql-0.1.4/src/ripple/static/focus.js +175 -0
- ripple_sql-0.1.2/src/ripple/static/explore.js +0 -136
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/.gitignore +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/LICENSE +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/README.md +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/__init__.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/answer.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/cache.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/ci.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/ci_signature.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/cli.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/doctor.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/__init__.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/budget.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/column_lineage.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/column_ref.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/dependencies.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/dialect.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/dispatch.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/extraction.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/jinja.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/json_sources.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/macro_source.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/pipeline.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/preprocess.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/safe_gen.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/schema_qualification.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/scope.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/select_sources.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/sql_script.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/tech_debt.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/tsql_catalog.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/tsql_scalar_vars.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/tsql_tvf.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/tsql_xml.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/types.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/unused_deps.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/engine/validation.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/graph.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/loaders/__init__.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/loaders/dbt.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/loaders/dbt_config.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/loaders/identity.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/loaders/sidecar.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/loaders/sqldir.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/loaders/types.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/lookml.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/mcp_server.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/names.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/project.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/py.typed +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/render_shims.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/schemas.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/semantic.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/sourcefiles.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/star_resolution.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/static/answer_twin.js +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/usage/__init__.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/usage/cli.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/usage/collect.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/usage/discover.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/usage/ingest.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.4}/src/ripple/usage/report.py +0 -0
|
@@ -6,6 +6,20 @@ Notable changes to Ripple. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.4] - 2026-09-09
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- A large project opens on an index, not a wall of chips. Past forty models the home page lists where things live (a dbt folder, a schema, a BigQuery dataset) in the order the data flows, each with the models most read by other models in view and the rest behind "more"; hovering a group tints what feeds it and what reads it. Clicking any model opens its own neighbourhood: what feeds it on the left, what reads it on the right, wires between them, another step on request, its columns under it to ask. The address bar carries the model, so Back and a shared link land on the same view. Measured on Mozilla's bigquery-etl (2,365 tables) and a synthetic 5,500-model project: the old page showed 6 percent of the models in a page ten screens tall; the new one shows every group on one screen and any model in one click.
|
|
13
|
+
- The question box finds as you type: models and columns from the map the page already holds, eight at a time, arrows to pick, Enter to go. A datalist did this before and put every column on the page as an option; at 5,500 models that was 99,000 elements and most of a 5.3 MB page. The page is 3.0 MB now, and 1.4 MB for Mozilla.
|
|
14
|
+
|
|
15
|
+
## [0.1.3] - 2026-09-09
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- The map of a large project is compact. Past forty models the columns-and-wires layout gave a page-tall column per layer, every link drawn at rest, and long names clipped at the edge (Wikimedia's dbt-jobs: 48 sources, 71 models in one layer). Now the layers stack as wrapped grids, nothing is wired at rest, each layer leads with the models most read and folds the rest behind "more", hovering a model lights its chain (what feeds it in teal, what reads it in ink) and clicking keeps it lit while its columns unfold, and typing hides every model that does not match, with the count of what it hid. Every chip carries the number of models that read it, and the key says so. A small project keeps the columns and wires.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- Two Spark shapes from Wikimedia's dbt-jobs, the first outside repo the published build was tried on. A bare table-generating function in a select list names its own output columns: `INLINE(ARRAY(STRUCT('a' AS platform)))` yields `platform`, `EXPLODE(x)` yields `col`, `POSEXPLODE(x)` yields `pos` and `col`; a lookup table built that way reported no columns at all. dbt's `as_bool`, `as_number`, `as_native` and `as_text` filters are registered on the Jinja environment, so a config that uses one renders instead of failing the whole model.
|
|
22
|
+
|
|
9
23
|
## [0.1.2] - 2026-09-09
|
|
10
24
|
|
|
11
25
|
### Fixed
|
|
@@ -235,7 +249,9 @@ First public release.
|
|
|
235
249
|
### Removed
|
|
236
250
|
- The dark whole-graph canvas that `ripple serve` used to open (`static/index.html`). Every surface now draws one answer.
|
|
237
251
|
|
|
238
|
-
[Unreleased]: https://github.com/bteh/ripple/compare/v0.1.
|
|
252
|
+
[Unreleased]: https://github.com/bteh/ripple/compare/v0.1.4...HEAD
|
|
253
|
+
[0.1.4]: https://github.com/bteh/ripple/releases/tag/v0.1.4
|
|
254
|
+
[0.1.3]: https://github.com/bteh/ripple/releases/tag/v0.1.3
|
|
239
255
|
[0.1.2]: https://github.com/bteh/ripple/releases/tag/v0.1.2
|
|
240
256
|
[0.1.1]: https://github.com/bteh/ripple/releases/tag/v0.1.1
|
|
241
257
|
[0.1.0]: https://github.com/bteh/ripple/releases/tag/v0.1.0
|
|
@@ -24,6 +24,8 @@ TEMPLATE = Path(__file__).parent / "static" / "answer.html"
|
|
|
24
24
|
STYLE = Path(__file__).parent / "static" / "answer.css"
|
|
25
25
|
TWIN = Path(__file__).parent / "static" / "answer_twin.js"
|
|
26
26
|
EXPLORE = Path(__file__).parent / "static" / "explore.js"
|
|
27
|
+
FOCUS = Path(__file__).parent / "static" / "focus.js"
|
|
28
|
+
FIND = Path(__file__).parent / "static" / "find.js"
|
|
27
29
|
EMBED_BUDGET_ENV = "RIPPLE_EMBED_GRAPH_BYTES"
|
|
28
30
|
DEFAULT_EMBED_BUDGET = 5_000_000
|
|
29
31
|
TRUST_CODE = {"verified": "v", "high_confidence": "h", "moderate": "m", "review_required": "r"}
|
|
@@ -161,6 +163,8 @@ def _page(data: dict, title: str) -> str:
|
|
|
161
163
|
.replace("/*CSS*/", STYLE.read_text(encoding="utf-8"))
|
|
162
164
|
.replace("/*TWIN*/", TWIN.read_text(encoding="utf-8"))
|
|
163
165
|
.replace("/*EXPLORE*/", EXPLORE.read_text(encoding="utf-8"))
|
|
166
|
+
.replace("/*FOCUS*/", FOCUS.read_text(encoding="utf-8"))
|
|
167
|
+
.replace("/*FIND*/", FIND.read_text(encoding="utf-8"))
|
|
164
168
|
)
|
|
165
169
|
return html.replace("__TITLE__", _escape(title)).replace("__DATA__", blob)
|
|
166
170
|
|
|
@@ -39,6 +39,7 @@ from ripple.engine.column_ref import (
|
|
|
39
39
|
under_subquery_where,
|
|
40
40
|
unwrap_select,
|
|
41
41
|
)
|
|
42
|
+
from ripple.engine.generators import name_generator_outputs
|
|
42
43
|
from ripple.engine.preprocess import restore_jinja_dots
|
|
43
44
|
from ripple.engine.safe_gen import safe_sql
|
|
44
45
|
from ripple.engine.scope import register_table
|
|
@@ -120,6 +121,7 @@ def extract_cte_column_lineage(
|
|
|
120
121
|
|
|
121
122
|
cleaned_sql, _ = prepare_sql_for_parse(raw_sql, dialect, clean_jinja_func)
|
|
122
123
|
parsed = sqlglot.parse_one(cleaned_sql, dialect=dialect)
|
|
124
|
+
parsed = name_generator_outputs(parsed)
|
|
123
125
|
if (dialect or "").lower() == "tsql":
|
|
124
126
|
from ripple.engine.tsql_xml import rewrite_xml_method_calls
|
|
125
127
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Bare table-generating functions name their own output columns.
|
|
2
|
+
|
|
3
|
+
Spark's `SELECT INLINE(ARRAY(STRUCT('a' AS platform)))` has no alias and
|
|
4
|
+
no column reference; its output columns are the struct's fields. A bare
|
|
5
|
+
`EXPLODE(x)` is a column called `col`, `POSEXPLODE(x)` is `pos` and `col`.
|
|
6
|
+
sqlglot leaves such items nameless, and the projection walks named them by
|
|
7
|
+
their SQL text or dropped them, so a lookup table built this way reported
|
|
8
|
+
no columns at all (Wikimedia dbt-jobs, platforms_ephemeral).
|
|
9
|
+
|
|
10
|
+
The rewrite gives each such item its default names as aliases before any
|
|
11
|
+
projection walk runs; a field over a column keeps the column as its source.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from sqlglot import exp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def output_names(item: exp.Expression) -> list[str] | None:
|
|
20
|
+
"""The default output columns of a bare generator, None for anything else."""
|
|
21
|
+
if isinstance(item, exp.Inline):
|
|
22
|
+
array = item.this
|
|
23
|
+
if not (isinstance(array, exp.Array) and array.expressions):
|
|
24
|
+
return None
|
|
25
|
+
first = array.expressions[0]
|
|
26
|
+
if not isinstance(first, exp.Struct):
|
|
27
|
+
return None
|
|
28
|
+
names: list[str] = []
|
|
29
|
+
for field in first.expressions:
|
|
30
|
+
if isinstance(field, exp.PropertyEQ):
|
|
31
|
+
names.append(field.this.name)
|
|
32
|
+
elif isinstance(field, exp.Alias):
|
|
33
|
+
names.append(field.alias)
|
|
34
|
+
elif isinstance(field, exp.Column):
|
|
35
|
+
names.append(field.name)
|
|
36
|
+
else:
|
|
37
|
+
return None
|
|
38
|
+
return names or None
|
|
39
|
+
if isinstance(item, exp.Posexplode):
|
|
40
|
+
return ["pos", "col"]
|
|
41
|
+
if isinstance(item, exp.Explode):
|
|
42
|
+
return ["col"]
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def name_generator_outputs(tree: exp.Expression) -> exp.Expression:
|
|
47
|
+
"""Alias every bare generator in every select list with its default
|
|
48
|
+
output names, in place, and return the tree."""
|
|
49
|
+
for select in list(tree.find_all(exp.Select)):
|
|
50
|
+
items = select.expressions
|
|
51
|
+
if not any(output_names(item) for item in items):
|
|
52
|
+
continue
|
|
53
|
+
renamed: list[exp.Expression] = []
|
|
54
|
+
for item in items:
|
|
55
|
+
names = output_names(item)
|
|
56
|
+
if not names:
|
|
57
|
+
renamed.append(item)
|
|
58
|
+
continue
|
|
59
|
+
renamed.extend(exp.alias_(item.copy(), name) for name in names)
|
|
60
|
+
select.set("expressions", renamed)
|
|
61
|
+
return tree
|
|
@@ -26,6 +26,7 @@ from ripple.engine.cte_tracing import (
|
|
|
26
26
|
star_except_spellings,
|
|
27
27
|
trace_through_ctes,
|
|
28
28
|
)
|
|
29
|
+
from ripple.engine.generators import name_generator_outputs
|
|
29
30
|
from ripple.engine.preprocess import (
|
|
30
31
|
has_unresolved_identifier,
|
|
31
32
|
prepare_sql_for_parse,
|
|
@@ -233,6 +234,7 @@ def extract_column_lineage_fast(
|
|
|
233
234
|
logger.debug("Parsing succeeded with minimal cleaning after full cleaning failed")
|
|
234
235
|
except Exception:
|
|
235
236
|
raise full_clean_error from None
|
|
237
|
+
parsed = name_generator_outputs(parsed)
|
|
236
238
|
|
|
237
239
|
# Validate the parsed result is a valid SELECT-type statement
|
|
238
240
|
if not isinstance(parsed, (exp.Select, exp.Union, exp.Except, exp.Intersect)):
|
|
@@ -39,19 +39,6 @@ def starters(edges, limit: int = STARTERS) -> list[dict]:
|
|
|
39
39
|
return [{"id": cid, "readers": len(models)} for cid, models in ranked[:limit]]
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
def column_ids(edges) -> list[str]:
|
|
43
|
-
"""Every column that takes part in a value link, for the question box's
|
|
44
|
-
suggestions. Dashboard entries and star placeholders stay out."""
|
|
45
|
-
ids: set[str] = set()
|
|
46
|
-
for e in edges:
|
|
47
|
-
if e.kind != "value":
|
|
48
|
-
continue
|
|
49
|
-
for model, column in ((e.src_model, e.src_column), (e.dst_model, e.dst_column)):
|
|
50
|
-
if column != "*" and not model.startswith(DASHBOARD_PREFIXES):
|
|
51
|
-
ids.add(f"{model}.{column}")
|
|
52
|
-
return sorted(ids)
|
|
53
|
-
|
|
54
|
-
|
|
55
42
|
def coverage_row(stats: dict, unresolved: list[dict]) -> dict:
|
|
56
43
|
"""Rung one: links that need review and the external tables behind them."""
|
|
57
44
|
links = _plural(stats["edges"], "column link")
|
|
@@ -167,7 +154,97 @@ def model_map(nodes: list[dict], edges) -> dict:
|
|
|
167
154
|
links = _links(edges)
|
|
168
155
|
layers = _layers(members, links)
|
|
169
156
|
models = [{**m, "layer": layers[m["id"]]} for m in members.values()]
|
|
170
|
-
|
|
157
|
+
_name_groups(models)
|
|
158
|
+
ordered = _ordered(models, links)
|
|
159
|
+
return {"models": ordered, "links": links, "groups": _groups(ordered, links)}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def group_of(member: dict) -> str:
|
|
163
|
+
"""Where a model lives, as a person would say it: the folder of a model,
|
|
164
|
+
the schema of a source table, "dashboards" for a dashboard number.
|
|
165
|
+
|
|
166
|
+
A file named after its model sits in its folder (`models/marts/orders.sql`
|
|
167
|
+
is in `models/marts`). A file with a generic name names its folder as the
|
|
168
|
+
table (`telemetry_derived/clients_v1/query.sql`), so the group is the
|
|
169
|
+
folder above. A model with no path is in "models"."""
|
|
170
|
+
if member["kind"] == "dashboard":
|
|
171
|
+
return "dashboards"
|
|
172
|
+
if member["kind"] == "source":
|
|
173
|
+
schema, dot, _ = member["id"].rpartition(".")
|
|
174
|
+
return schema if dot else "sources"
|
|
175
|
+
path = member.get("path")
|
|
176
|
+
if not path:
|
|
177
|
+
return "models"
|
|
178
|
+
parts = path.replace("\\", "/").split("/")
|
|
179
|
+
stem = parts[-1].rsplit(".", 1)[0]
|
|
180
|
+
folders = parts[:-1]
|
|
181
|
+
if folders and stem != member["id"]:
|
|
182
|
+
folders = folders[:-1]
|
|
183
|
+
return "/".join(folders) or "models"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _shorten(names: set[str], sep: str) -> dict[str, str]:
|
|
187
|
+
"""Drop the leading segments every name shares, so `models/staging` and
|
|
188
|
+
`models/marts` read as `staging` and `marts`. A lone name keeps itself."""
|
|
189
|
+
parts = {n: n.split(sep) for n in names}
|
|
190
|
+
while True:
|
|
191
|
+
deep = [p for p in parts.values() if len(p) > 1]
|
|
192
|
+
if len(deep) < 2 or len({p[0] for p in deep}) != 1:
|
|
193
|
+
break
|
|
194
|
+
parts = {n: p[1:] if len(p) > 1 else p for n, p in parts.items()}
|
|
195
|
+
return {n: sep.join(p) for n, p in parts.items()}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
SOURCE_SCHEMAS = 6
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _name_groups(models: list[dict]) -> None:
|
|
202
|
+
"""Set each model's group, shortened per kind, in place. A project that
|
|
203
|
+
reads from more than a handful of schemas gets one "sources" group: a
|
|
204
|
+
row per schema would be a page of one-table rows, and the chip already
|
|
205
|
+
carries the schema in its name."""
|
|
206
|
+
raw = {m["id"]: group_of(m) for m in models}
|
|
207
|
+
short: dict[str, str] = {}
|
|
208
|
+
for kind, sep in (("model", "/"), ("source", ".")):
|
|
209
|
+
names = {raw[m["id"]] for m in models if m["kind"] == kind}
|
|
210
|
+
if kind == "source" and len(names) > SOURCE_SCHEMAS:
|
|
211
|
+
short.update({n: "sources" for n in names})
|
|
212
|
+
continue
|
|
213
|
+
short.update(_shorten(names, sep))
|
|
214
|
+
for m in models:
|
|
215
|
+
m["group"] = short.get(raw[m["id"]], raw[m["id"]])
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
GROUP_RANK = {"source": 0, "model": 1, "dashboard": 2}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _groups(models: list[dict], links: list[dict]) -> list[dict]:
|
|
222
|
+
"""The groups in the order the data flows: source schemas, then model
|
|
223
|
+
folders by the mean layer of their models, then dashboards. Each carries
|
|
224
|
+
how many models it holds and how many models outside it read one of its."""
|
|
225
|
+
by: dict[str, dict] = {}
|
|
226
|
+
of: dict[str, str] = {}
|
|
227
|
+
for m in models:
|
|
228
|
+
g = by.setdefault(
|
|
229
|
+
m["group"],
|
|
230
|
+
{"id": m["group"], "kind": m["kind"], "n": 0, "readers": set(), "layers": []},
|
|
231
|
+
)
|
|
232
|
+
g["n"] += 1
|
|
233
|
+
g["layers"].append(m["layer"])
|
|
234
|
+
if GROUP_RANK[m["kind"]] > GROUP_RANK[g["kind"]]:
|
|
235
|
+
g["kind"] = m["kind"]
|
|
236
|
+
of[m["id"]] = m["group"]
|
|
237
|
+
for link in links:
|
|
238
|
+
src, dst = of.get(link["src"]), of.get(link["dst"])
|
|
239
|
+
if src and dst and src != dst:
|
|
240
|
+
by[src]["readers"].add(link["dst"])
|
|
241
|
+
out = sorted(
|
|
242
|
+
by.values(),
|
|
243
|
+
key=lambda g: (GROUP_RANK[g["kind"]], sum(g["layers"]) / len(g["layers"]), g["id"]),
|
|
244
|
+
)
|
|
245
|
+
return [
|
|
246
|
+
{"id": g["id"], "kind": g["kind"], "n": g["n"], "readers": len(g["readers"])} for g in out
|
|
247
|
+
]
|
|
171
248
|
|
|
172
249
|
|
|
173
250
|
def _ordered(models: list[dict], links: list[dict]) -> list[dict]:
|
|
@@ -275,6 +275,50 @@ class _Modules:
|
|
|
275
275
|
itertools = _itertools
|
|
276
276
|
|
|
277
277
|
|
|
278
|
+
def _as_bool(value):
|
|
279
|
+
"""dbt's as_bool filter: the strings a YAML reader calls true or false."""
|
|
280
|
+
if isinstance(value, bool):
|
|
281
|
+
return value
|
|
282
|
+
text = str(value).strip().lower()
|
|
283
|
+
if text in ("true", "yes", "on", "1"):
|
|
284
|
+
return True
|
|
285
|
+
if text in ("false", "no", "off", "0", "", "none", "null"):
|
|
286
|
+
return False
|
|
287
|
+
return bool(value)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _as_number(value):
|
|
291
|
+
try:
|
|
292
|
+
return int(value)
|
|
293
|
+
except (TypeError, ValueError):
|
|
294
|
+
pass
|
|
295
|
+
try:
|
|
296
|
+
return float(value)
|
|
297
|
+
except (TypeError, ValueError):
|
|
298
|
+
return value
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _as_native(value):
|
|
302
|
+
"""dbt's as_native: a string read back as YAML, anything else untouched."""
|
|
303
|
+
if not isinstance(value, str):
|
|
304
|
+
return value
|
|
305
|
+
try:
|
|
306
|
+
import yaml
|
|
307
|
+
|
|
308
|
+
return yaml.safe_load(value)
|
|
309
|
+
except Exception:
|
|
310
|
+
return value
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# the filters dbt registers on every project's environment
|
|
314
|
+
DBT_FILTERS = {
|
|
315
|
+
"as_bool": _as_bool,
|
|
316
|
+
"as_number": _as_number,
|
|
317
|
+
"as_native": _as_native,
|
|
318
|
+
"as_text": lambda value: "" if value is None else str(value),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
278
322
|
_SET_BLOCK_RE = re.compile(r"{%-?\s*(call|materialization|test)\b.*?{%-?\s*end\1\s*-?%}", re.S)
|
|
279
323
|
_MACRO_DEF_RE = re.compile(r"{%-?\s*macro\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(")
|
|
280
324
|
_MACRO_DEF_SUB_RE = re.compile(r"({%-?\s*macro\s+)([A-Za-z_][A-Za-z0-9_]*)(\s*\()")
|
|
@@ -299,6 +343,7 @@ def build_environment(
|
|
|
299
343
|
undefined=_Anything,
|
|
300
344
|
extensions=["jinja2.ext.do", "jinja2.ext.loopcontrols"],
|
|
301
345
|
)
|
|
346
|
+
env.filters.update(DBT_FILTERS)
|
|
302
347
|
dialect = registry.dialect if registry else "snowflake"
|
|
303
348
|
env.globals.update(
|
|
304
349
|
{
|
|
@@ -117,7 +117,6 @@ def home_page(service) -> str:
|
|
|
117
117
|
"review": stats["review_required_edges"],
|
|
118
118
|
"ladder": home.ladder_rows(stats, graph.unresolved_tables(), read_store(root)),
|
|
119
119
|
"starters": home.starters(graph.edges),
|
|
120
|
-
"columns": home.column_ids(graph.edges),
|
|
121
120
|
"map": home.model_map(graph.to_dict()["nodes"], graph.edges),
|
|
122
121
|
}
|
|
123
122
|
return answer_page.render_live(info, live)
|
|
@@ -28,7 +28,8 @@ h1 code{font:500 24px/1 var(--mono); letter-spacing:0}
|
|
|
28
28
|
|
|
29
29
|
.ask{display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:28px 0 0; padding:16px 0 14px; border-top:1px solid var(--line); border-bottom:1px solid var(--line)}
|
|
30
30
|
.ask input,.ask select{font:400 14px var(--mono); padding:8px 10px; border:1px solid var(--line-strong); border-radius:var(--r); background:var(--surface); color:var(--ink); min-width:0; height:36px}
|
|
31
|
-
.ask
|
|
31
|
+
.ask .findwrap{flex:1 1 280px; position:relative; min-width:0}
|
|
32
|
+
.ask .findwrap input{width:100%}
|
|
32
33
|
.ask input::placeholder{color:var(--faint)}
|
|
33
34
|
.ask select{font-family:var(--sans); padding-right:28px; appearance:none; -webkit-appearance:none;
|
|
34
35
|
background-image:linear-gradient(45deg,transparent 50%,var(--muted) 50%),linear-gradient(135deg,var(--muted) 50%,transparent 50%);
|
|
@@ -76,6 +77,7 @@ h1 code{font:500 24px/1 var(--mono); letter-spacing:0}
|
|
|
76
77
|
.key i.r{border-top-style:dashed; border-color:var(--review)}
|
|
77
78
|
.key s{display:inline-block; width:11px; height:11px; border-radius:2px; background:var(--ink); border:1px solid var(--ink); text-decoration:none}
|
|
78
79
|
.key s.src{background:var(--well); border:1px dashed var(--line-strong)}
|
|
80
|
+
.key small.n{font:400 11.5px/1 var(--sans); color:var(--faint); border:1px solid var(--line-strong); border-radius:var(--r); padding:2px 5px; background:var(--surface)}
|
|
79
81
|
.hint{margin-left:auto; font-size:12.5px; color:var(--faint)}
|
|
80
82
|
.rows{padding:0 22px 14px; font-size:13.5px; color:var(--muted)}
|
|
81
83
|
.rows b{color:var(--ink); font-weight:600}
|
|
@@ -111,6 +113,72 @@ pre{margin:0; padding:16px 22px 18px; overflow-x:auto; font:400 13px/1.65 var(--
|
|
|
111
113
|
.cols .node:hover{border-color:var(--accent); color:var(--accent-strong)}
|
|
112
114
|
.more-chips{font:500 12.5px var(--sans); color:var(--accent); background:none; border:0; padding:4px 0; cursor:pointer; text-align:left}
|
|
113
115
|
.more-chips:hover{text-decoration:underline}
|
|
116
|
+
/* a lit chain: the model you are on, what feeds it, what it feeds */
|
|
117
|
+
.mdl.sel,.mdl.sel:hover{background:var(--accent); color:#fff; border-color:var(--accent); border-style:solid}
|
|
118
|
+
.mdl.sel small{color:rgba(255,255,255,.7)}
|
|
119
|
+
.mdl.up{border-color:var(--accent); background:#E4F1F4; border-style:solid}
|
|
120
|
+
.mdl.down{border-color:var(--ink); background:var(--well); border-style:solid}
|
|
121
|
+
.key s.up{background:#E4F1F4; border:1px solid var(--accent)}
|
|
122
|
+
.key s.sel{background:var(--accent); border-color:var(--accent)}
|
|
123
|
+
.key s.down{background:var(--well); border:1px solid var(--ink)}
|
|
124
|
+
/* the index: a large project as rows of where things live, hubs first */
|
|
125
|
+
.map.compact .board{overflow-x:visible; padding:6px 22px 10px}
|
|
126
|
+
.map.compact .board-inner{display:block; min-width:0}
|
|
127
|
+
.map.compact.focused .board{overflow-x:auto; padding:22px 22px 20px}
|
|
128
|
+
.map.compact.focused .board-inner{display:flex; gap:32px; align-items:flex-start; min-width:max-content}
|
|
129
|
+
.index{display:grid; grid-template-columns:minmax(150px,220px) 1fr; column-gap:22px}
|
|
130
|
+
.grow{display:contents}
|
|
131
|
+
.grow > *{padding:12px 0 11px; border-top:1px solid var(--line)}
|
|
132
|
+
.grow:first-child > *{border-top:0}
|
|
133
|
+
.grow.miss{display:none}
|
|
134
|
+
.gcell{display:flex; flex-direction:column; align-items:flex-start; gap:3px; margin:0; padding-left:8px; padding-right:8px; margin-left:-8px; font:400 13px/1.3 var(--mono); color:var(--ink); background:none; border:0; border-top:1px solid var(--line); border-radius:var(--r); text-align:left; cursor:pointer; overflow-wrap:anywhere; transition:background .12s var(--ease)}
|
|
135
|
+
.grow:first-child .gcell{border-top:0}
|
|
136
|
+
.gcell .gid{display:block}
|
|
137
|
+
.gcell .gpath{display:block; font-size:11.5px; color:var(--faint)}
|
|
138
|
+
.gcell small{font:400 12px/1.3 var(--sans); color:var(--muted)}
|
|
139
|
+
.grow.sel .gpath{color:rgba(255,255,255,.72)}
|
|
140
|
+
.gcell:hover{background:var(--well)}
|
|
141
|
+
.grow.sel .gcell{background:var(--accent); color:#fff}
|
|
142
|
+
.grow.sel .gcell small{color:rgba(255,255,255,.72)}
|
|
143
|
+
.grow.up .gcell{background:#E4F1F4; box-shadow:inset 3px 0 0 var(--accent)}
|
|
144
|
+
.grow.down .gcell{background:var(--well); box-shadow:inset 3px 0 0 var(--ink)}
|
|
145
|
+
.index .chips{display:flex; flex-wrap:wrap; gap:6px 8px; align-items:center}
|
|
146
|
+
.index .mdl{max-width:100%; overflow-wrap:anywhere; text-align:left; line-height:1.25}
|
|
147
|
+
.index .gname{flex-basis:100%}
|
|
148
|
+
.map.compact .cols{flex-direction:row; flex-wrap:wrap; gap:6px 8px; margin:0 0 8px 14px; padding:8px 0 8px 10px}
|
|
149
|
+
.map.compact .cols .gname{flex-basis:100%; max-width:60ch}
|
|
150
|
+
.map.compact.focus .mdl:not(.on){opacity:.28}
|
|
151
|
+
/* focus: one model between what feeds it and what reads it */
|
|
152
|
+
.map-head{display:flex; flex-wrap:wrap; align-items:baseline; gap:4px 8px; padding:12px 22px 0; font:400 13px var(--sans); color:var(--muted)}
|
|
153
|
+
.crumb-home{font:500 13px var(--sans); color:var(--accent); background:none; border:0; padding:0; cursor:pointer}
|
|
154
|
+
.crumb-home:hover{text-decoration:underline}
|
|
155
|
+
.crumb-sep{color:var(--faint)}
|
|
156
|
+
.crumb-group{font:400 13px var(--mono); color:var(--muted)}
|
|
157
|
+
.crumb-here{font:500 13px var(--mono); color:var(--ink); overflow-wrap:anywhere}
|
|
158
|
+
.crumb-counts{margin-left:auto; color:var(--faint); font-variant-numeric:tabular-nums}
|
|
159
|
+
.map.compact.focused .lvl{min-width:150px; max-width:300px}
|
|
160
|
+
.map.compact.focused .lvl.asked{max-width:330px}
|
|
161
|
+
.map.compact.focused .lvl.up .lhead{color:var(--accent)}
|
|
162
|
+
.map.compact.focused .lvl.asked .lhead{color:var(--muted); font-weight:500; border-bottom-color:var(--line)}
|
|
163
|
+
.map.compact.focused .grp.open{display:flex; flex-direction:column}
|
|
164
|
+
.mdl.anchor{position:relative; font-weight:500}
|
|
165
|
+
.mdl.anchor::before,.mdl.anchor::after{content:""; position:absolute; inset:-5px; border:1px solid var(--accent-ring); border-radius:7px; pointer-events:none}
|
|
166
|
+
.mdl.anchor::after{inset:-10px; border-radius:11px; border-color:rgba(10,124,147,.14)}
|
|
167
|
+
.map.compact.focused .cols{flex-direction:row; flex-wrap:wrap; gap:6px 8px; margin:4px 0 6px 10px; padding:6px 0 0 10px; max-width:320px}
|
|
168
|
+
.map.compact.focused .cols .gname{flex-basis:100%; max-width:40ch}
|
|
169
|
+
.hop{align-self:flex-start; display:inline-flex; align-items:baseline; gap:6px; font:500 12.5px var(--sans); color:var(--accent); background:none; border:1px dashed var(--line-strong); border-radius:var(--r); padding:6px 9px; margin-top:2px; cursor:pointer; white-space:nowrap}
|
|
170
|
+
.hop small{font:400 11.5px var(--sans); color:var(--faint)}
|
|
171
|
+
.hop:hover{border-color:var(--accent)}
|
|
172
|
+
.more-groups{padding:14px 0 4px}
|
|
173
|
+
.wires path.o{stroke:var(--accent-soft); stroke-width:1.5}
|
|
174
|
+
/* the question box finds as you type */
|
|
175
|
+
.find{position:absolute; z-index:20; left:0; right:0; top:calc(100% + 4px); margin:0; padding:4px 0; list-style:none; background:var(--surface); border:1px solid var(--line-strong); border-radius:var(--r); box-shadow:0 8px 24px rgba(14,27,38,.10)}
|
|
176
|
+
.find li{display:flex; align-items:baseline; justify-content:space-between; gap:12px; padding:7px 10px; font:400 13px var(--mono); color:var(--ink); cursor:pointer}
|
|
177
|
+
.find li b{font-weight:600; color:var(--accent-strong)}
|
|
178
|
+
.find li small{font:400 12px var(--sans); color:var(--faint); white-space:nowrap}
|
|
179
|
+
.find li[aria-selected="true"]{background:var(--well)}
|
|
180
|
+
.find .fid{overflow-wrap:anywhere}
|
|
181
|
+
.map .hidden-note{color:var(--muted)}
|
|
114
182
|
|
|
115
183
|
.ladder{margin:26px 0 0; display:grid; grid-template-columns:max-content 1fr; gap:12px 20px; align-items:baseline}
|
|
116
184
|
.ladder p{display:contents; margin:0}
|
|
@@ -143,6 +211,10 @@ pre{margin:0; padding:16px 22px 18px; overflow-x:auto; font:400 13px/1.65 var(--
|
|
|
143
211
|
.lvl{min-width:0}
|
|
144
212
|
.lvl+.lvl::before{content:'↓'; color:var(--faint); font-size:14px; margin-top:-14px}
|
|
145
213
|
.hint{margin-left:0; flex-basis:100%}
|
|
214
|
+
.index{grid-template-columns:1fr}
|
|
215
|
+
.grow > *{border-top:0; padding:6px 0}
|
|
216
|
+
.grow .gcell{border-top:1px solid var(--line); padding-top:12px}
|
|
217
|
+
.crumb-counts{margin-left:0; flex-basis:100%}
|
|
146
218
|
.ladder{grid-template-columns:1fr; gap:4px 0}
|
|
147
219
|
.ladder .rung{margin-top:10px}
|
|
148
220
|
}
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
<div class="change" id="change" hidden><span class="lab">In this change</span></div>
|
|
19
19
|
|
|
20
20
|
<form class="ask" id="ask" hidden>
|
|
21
|
-
<
|
|
22
|
-
<datalist id="cols"></datalist>
|
|
21
|
+
<div class="findwrap"><input id="q" placeholder="ask about another column: model.column" autocomplete="off" spellcheck="false"><ul class="find" id="find" hidden></ul></div>
|
|
23
22
|
<select id="dir" aria-label="direction"><option value="breaks">what breaks</option><option value="trace">where it comes from</option></select>
|
|
24
23
|
<button type="submit">Ask</button>
|
|
25
24
|
<p class="fine" id="askfine">Answered inside this file. Nothing sent.</p>
|
|
@@ -27,12 +26,16 @@
|
|
|
27
26
|
</form>
|
|
28
27
|
|
|
29
28
|
<section class="map" id="map" hidden aria-label="every model in this project">
|
|
29
|
+
<div class="map-head" id="maphead" hidden></div>
|
|
30
30
|
<div class="board"><div class="board-inner" id="mapboard"><svg class="wires" id="mapwires" aria-hidden="true"></svg></div></div>
|
|
31
31
|
<div class="board-foot">
|
|
32
|
-
<span class="key"><i></i> feeds</span>
|
|
32
|
+
<span class="key" id="key-feeds"><i></i> feeds</span>
|
|
33
33
|
<span class="key"><s class="src"></s> a source table</span>
|
|
34
34
|
<span class="key"><s></s> a dashboard number</span>
|
|
35
35
|
<span class="key"><i class="r"></i> needs review</span>
|
|
36
|
+
<span class="key" id="key-chain" hidden><s class="up"></s> feeds it <s class="sel"></s> chosen <s class="down"></s> reads it</span>
|
|
37
|
+
<span class="key"><small class="n">3</small> models that read it</span>
|
|
38
|
+
<span class="hidden-note" id="maphidden" hidden></span>
|
|
36
39
|
<span class="hint" id="maphint">Hover lights the chain. Click a model for its columns, a column to ask.</span>
|
|
37
40
|
</div>
|
|
38
41
|
</section>
|
|
@@ -73,6 +76,10 @@
|
|
|
73
76
|
|
|
74
77
|
/*EXPLORE*/
|
|
75
78
|
|
|
79
|
+
/*FOCUS*/
|
|
80
|
+
|
|
81
|
+
/*FIND*/
|
|
82
|
+
|
|
76
83
|
function show(a, text){
|
|
77
84
|
current = a;
|
|
78
85
|
demo.hidden = false;
|
|
@@ -220,12 +227,14 @@
|
|
|
220
227
|
}
|
|
221
228
|
|
|
222
229
|
// SERVE START
|
|
223
|
-
var LIVE_FINE = '
|
|
230
|
+
var LIVE_FINE = 'Read from your SQL files as they are now, on this machine. No warehouse login, nothing sent.';
|
|
224
231
|
// ripple serve: the question box asks the local server, which answers from
|
|
225
232
|
// the files as they are now. Relative path only; the file page never runs this.
|
|
233
|
+
var HINT_SMALL = 'Hover lights the chain. Click a model for its columns, a column to ask.';
|
|
234
|
+
var HINT_INDEX = 'Hover a group to see what it feeds. Click a model for what feeds it and what reads it; type to find anything.';
|
|
235
|
+
var HINT_FOCUS = 'Click a model to move to it, a column to ask. Esc goes back to all models.';
|
|
236
|
+
function hint(text){ document.getElementById('maphint').textContent = text; }
|
|
226
237
|
if (L){
|
|
227
|
-
var list0 = document.getElementById('cols');
|
|
228
|
-
L.columns.forEach(function(id){ var o = document.createElement('option'); o.value = id; list0.appendChild(o); });
|
|
229
238
|
var form0 = document.getElementById('ask'); form0.hidden = false;
|
|
230
239
|
var strip0 = document.getElementById('starters');
|
|
231
240
|
if (L.starters.length){
|
|
@@ -238,11 +247,39 @@
|
|
|
238
247
|
});
|
|
239
248
|
}
|
|
240
249
|
if (L.map && L.map.models.length){
|
|
241
|
-
|
|
242
|
-
|
|
250
|
+
var hiddenNote = document.getElementById('maphidden');
|
|
251
|
+
mapApi = renderMap(mapEl, L.map, {esc: esc, reduce: reduce,
|
|
252
|
+
onAsk: function(id){ document.getElementById('q').value = id; ask(id, document.getElementById('dir').value); },
|
|
253
|
+
onFocus: function(id){ go(id, true); },
|
|
254
|
+
onHome: function(){ goHome(true); },
|
|
255
|
+
onFilter: function(n){ hiddenNote.hidden = !n; hiddenNote.textContent = n ? plural(n, 'model') + ' hidden by the filter' : ''; }});
|
|
256
|
+
if (mapApi.compact){
|
|
257
|
+
document.getElementById('key-feeds').hidden = true;
|
|
258
|
+
document.getElementById('key-chain').hidden = false;
|
|
259
|
+
}
|
|
243
260
|
document.getElementById('q').addEventListener('input', function(){ if (!mapEl.hidden) mapApi.filter(this.value.trim()); });
|
|
244
|
-
document.getElementById('tomap').addEventListener('click', function(){
|
|
261
|
+
document.getElementById('tomap').addEventListener('click', function(){ goHome(true); });
|
|
245
262
|
}
|
|
263
|
+
// the question box finds models and columns from the map as you type
|
|
264
|
+
var found = null;
|
|
265
|
+
attachFind(document.getElementById('q'), {esc: esc, list: document.getElementById('find'),
|
|
266
|
+
candidates: function(){
|
|
267
|
+
if (found) return found;
|
|
268
|
+
found = [];
|
|
269
|
+
if (!L.map) return found;
|
|
270
|
+
var feeds = {};
|
|
271
|
+
L.map.links.forEach(function(l){ feeds[l.src] = (feeds[l.src] || 0) + 1; });
|
|
272
|
+
L.map.models.forEach(function(m){
|
|
273
|
+
found.push({id: m.id, kind: m.kind, feeds: feeds[m.id] || 0});
|
|
274
|
+
if (m.kind !== 'dashboard') m.columns.forEach(function(c){ found.push({id: m.id + '.' + c, kind: 'column', feeds: feeds[m.id] || 0}); });
|
|
275
|
+
});
|
|
276
|
+
return found;
|
|
277
|
+
},
|
|
278
|
+
onPick: function(c){
|
|
279
|
+
if (c.kind === 'column' || !mapApi){ ask(c.id, document.getElementById('dir').value); return; }
|
|
280
|
+
document.getElementById('q').value = '';
|
|
281
|
+
go(c.id, true);
|
|
282
|
+
}});
|
|
246
283
|
var ladder = document.getElementById('ladder'); ladder.hidden = false;
|
|
247
284
|
['Coverage', 'Usage'].forEach(function(name, i){
|
|
248
285
|
var row = L.ladder[i], p = document.createElement('p');
|
|
@@ -259,10 +296,34 @@
|
|
|
259
296
|
form0.addEventListener('submit', function(ev){
|
|
260
297
|
ev.preventDefault();
|
|
261
298
|
var target = document.getElementById('q').value.trim();
|
|
262
|
-
if (target)
|
|
299
|
+
if (!target) return;
|
|
300
|
+
if (mapApi && !/\./.test(target) && mapApi.focus(target)){ document.getElementById('q').value = ''; go(target, true); return; }
|
|
301
|
+
ask(target, document.getElementById('dir').value);
|
|
263
302
|
});
|
|
264
303
|
}
|
|
265
|
-
|
|
304
|
+
// one model's neighbourhood, the index of all models, or an answer: the
|
|
305
|
+
// address bar says which, so Back and a shared link land on the same view
|
|
306
|
+
function go(id, push){
|
|
307
|
+
if (current || mapEl.hidden) home();
|
|
308
|
+
if (!mapApi.focus(id)){ fine.textContent = 'No model called ' + id + ' in this project.'; return; }
|
|
309
|
+
hint(mapApi.compact ? HINT_FOCUS : HINT_SMALL);
|
|
310
|
+
if (push) history.pushState({m: id}, '', '?m=' + encodeURIComponent(id));
|
|
311
|
+
}
|
|
312
|
+
function goHome(push){
|
|
313
|
+
if (current || mapEl.hidden) home();
|
|
314
|
+
if (mapApi){ mapApi.home(); hint(mapApi.compact ? HINT_INDEX : HINT_SMALL); requestAnimationFrame(mapApi.draw); }
|
|
315
|
+
if (push) history.pushState({}, '', location.pathname);
|
|
316
|
+
}
|
|
317
|
+
function route(params){
|
|
318
|
+
var q = params.get('q'), m = params.get('m');
|
|
319
|
+
if (q){
|
|
320
|
+
document.getElementById('q').value = q;
|
|
321
|
+
document.getElementById('dir').value = params.get('dir') === 'trace' ? 'trace' : 'breaks';
|
|
322
|
+
ask(q, document.getElementById('dir').value, true);
|
|
323
|
+
} else if (m && mapApi) go(m, false);
|
|
324
|
+
else goHome(false);
|
|
325
|
+
}
|
|
326
|
+
function ask(target, dir, silent){
|
|
266
327
|
fine.textContent = 'asking the local server…';
|
|
267
328
|
fetch(L.api + '/' + dir + '?target=' + encodeURIComponent(target), {cache: 'no-store'}).then(function(r){
|
|
268
329
|
return r.json().then(function(j){ return [r.ok, j]; });
|
|
@@ -276,7 +337,7 @@
|
|
|
276
337
|
}
|
|
277
338
|
fine.textContent = LIVE_FINE;
|
|
278
339
|
show(j.answer, j.text);
|
|
279
|
-
history.
|
|
340
|
+
if (!silent) history.pushState({q: target}, '', '?q=' + encodeURIComponent(target) + '&dir=' + dir);
|
|
280
341
|
}).catch(function(){ fine.textContent = 'The local server did not answer. Is ripple serve still running?'; });
|
|
281
342
|
}
|
|
282
343
|
// SERVE END
|
|
@@ -285,22 +346,29 @@
|
|
|
285
346
|
var G = DATA.graph, index = G ? buildIndex(G) : null;
|
|
286
347
|
function offline(target, dir){ return dir === 'trace' ? computeTrace(index, target, noun) : computeBreaks(index, target, noun); }
|
|
287
348
|
if (G){
|
|
288
|
-
var list = document.getElementById('cols');
|
|
289
|
-
Object.keys(index.ids).sort().forEach(function(id){ if (id.indexOf('(') < 0 && id.slice(-2) !== '.*'){ var o = document.createElement('option'); o.value = id; list.appendChild(o); } });
|
|
290
349
|
var form = document.getElementById('ask'); form.hidden = false;
|
|
291
350
|
document.getElementById('q').value = '';
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
351
|
+
var ids0 = null;
|
|
352
|
+
attachFind(document.getElementById('q'), {esc: esc, list: document.getElementById('find'),
|
|
353
|
+
candidates: function(){
|
|
354
|
+
if (!ids0) ids0 = Object.keys(index.ids).sort().filter(function(id){ return id.indexOf('(') < 0 && id.slice(-2) !== '.*'; }).map(function(id){ return {id: id, kind: 'column'}; });
|
|
355
|
+
return ids0;
|
|
356
|
+
},
|
|
357
|
+
onPick: function(c){ askOffline(c.id, document.getElementById('dir').value); }});
|
|
358
|
+
function askOffline(target, dir){
|
|
359
|
+
var hit = resolveTarget(index, target), fine2 = document.getElementById('askfine');
|
|
360
|
+
if (!hit.id){
|
|
361
|
+
fine2.textContent = hit.error + '.' + (hit.candidates && hit.candidates.length ? ' Did you mean: ' + hit.candidates.join(', ') + '?' : '');
|
|
299
362
|
return;
|
|
300
363
|
}
|
|
301
364
|
fine2.textContent = 'Answered inside this file. Nothing sent.';
|
|
302
|
-
var a = offline(
|
|
365
|
+
var a = offline(hit.id, dir);
|
|
303
366
|
show(a, dir === 'trace' ? traceText(a) : breaksText(a));
|
|
367
|
+
}
|
|
368
|
+
form.addEventListener('submit', function(ev){
|
|
369
|
+
ev.preventDefault();
|
|
370
|
+
var target = document.getElementById('q').value.trim();
|
|
371
|
+
if (target) askOffline(target, document.getElementById('dir').value);
|
|
304
372
|
});
|
|
305
373
|
}
|
|
306
374
|
|
|
@@ -345,8 +413,8 @@
|
|
|
345
413
|
var dir0 = params.get('dir') === 'trace' ? 'trace' : 'breaks';
|
|
346
414
|
var linked = (G && asked) ? resolveTarget(index, asked) : null;
|
|
347
415
|
if (L){
|
|
348
|
-
|
|
349
|
-
|
|
416
|
+
route(params);
|
|
417
|
+
window.addEventListener('popstate', function(){ route(new URLSearchParams(location.search)); });
|
|
350
418
|
} else if (linked && linked.id){
|
|
351
419
|
document.getElementById('q').value = asked; document.getElementById('dir').value = dir0;
|
|
352
420
|
var a0 = offline(linked.id, dir0);
|