Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sparql-anything-builder-tweaks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@matdata/yasgui": minor
---

SPARQL Anything source inspector: **Load structure** on the chip (and in the Ctrl+K search) now opens the Source view of the side panel. The **Build query** view has one compact bar at the top with an **All** checkbox to tick every value of the source at once, **Replace block**, and icon buttons to insert at the cursor or open in a new tab.
4 changes: 2 additions & 2 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1071,7 +1071,7 @@ SELECT * WHERE {

**Loading the structure**

As soon as you close the block with `}`, a chip appears after its opening brace, with the file name and **Load structure**. Clicking it sends one query to the endpoint of the tab, with your SERVICE IRI and `fx:properties` options unchanged. The endpoint reads the file, so this also works for files that only the endpoint can reach (a path on the server, a URL behind a firewall, or a URL that needs authentication). Nothing is sent before you click: SPARQL Anything reads the whole file to answer, which can take a while for big files. While it runs the chip offers **Cancel**; when it fails it shows **Retry**, with the endpoint's error when you hover it.
As soon as you close the block with `}`, a chip appears after its opening brace, with the file name and **Load structure**. Clicking it opens the **Source** view of the side panel and sends one query to the endpoint of the tab, with your SERVICE IRI and `fx:properties` options unchanged. The endpoint reads the file, so this also works for files that only the endpoint can reach (a path on the server, a URL behind a firewall, or a URL that needs authentication). Nothing is sent before you click: SPARQL Anything reads the whole file to answer, which can take a while for big files. While it runs the chip offers **Cancel**; when it fails it shows **Retry**, with the endpoint's error when you hover it.

When the structure is loaded, the chip shows the number of properties and **Inspect**, and:

Expand All @@ -1084,7 +1084,7 @@ The structure is remembered per endpoint and set of options for a day; the reloa

- **Structure**: the tree of the file as SPARQL Anything sees it: the document (`fx:root`), its rows, array items or elements, and their properties, each with a count and an example value. Click a property or element to insert it at the cursor.
- **Preview**: the first 100 triples of the converted file, in Turtle, fetched with a `CONSTRUCT ... LIMIT 100` query. **Open as query** puts that query in a new tab.
- **Build query**: tick the values you need. MatGUI writes the patterns from the root to each value: `fx:anySlot` for rows and array items, `a xyz:<element>` for XML elements, and `OPTIONAL` for values you mark as optional (the first value you tick is required, later ones optional; click the label to switch). **Replace block** puts the patterns in the SERVICE block (keeping its `fx:properties` options; `Ctrl+Z` undoes it), **Insert at cursor** inserts them, and **New tab** opens a complete SELECT query.
- **Build query**: tick the values you need, or **All** at the top to tick them all (click it again to clear the selection; next to it you see how many are ticked). MatGUI writes the patterns from the root to each value: `fx:anySlot` for rows and array items, `a xyz:<element>` for XML elements, and `OPTIONAL` for values you mark as optional (the first value you tick is required, later ones optional; click the label to switch). The bar with **All** stays at the top of the view and has the actions: **Replace block** puts the patterns in the SERVICE block (keeping its `fx:properties` options; `Ctrl+Z` undoes it), the text cursor button inserts them at the cursor, and the arrow button opens a complete SELECT query in a new tab.

CSV, JSON and XML sources are shown best; other formats SPARQL Anything supports work too, with a more generic tree. A site can switch all of this off with `sparqlAnything: false`. To try it, the repository has a Docker Compose setup with a SPARQL Anything server and sample files in `dev/sparql-anything`.

Expand Down
4 changes: 4 additions & 0 deletions packages/yasgui/src/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ const paths = {
'<path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M4.268 21a2 2 0 0 0 1.727 1H18a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v3"/><path d="m9 18-1.5-1.5"/><circle cx="5" cy="14" r="3"/>',
refresh:
'<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
externalLink:
'<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
textCursor:
'<path d="M17 22h-1a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h1"/><path d="M7 22h1a4 4 0 0 0 4-4v-1"/><path d="M7 2h1a4 4 0 0 1 4 4v1"/>',
cornerDownLeft: '<path d="M20 4v7a4 4 0 0 1-4 4H4"/><path d="m9 10-5 5 5 5"/>',
code: '<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>',
command: '<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"/>',
Expand Down
147 changes: 96 additions & 51 deletions packages/yasgui/src/sparqlAnything/SourceView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,98 @@ export default class SourceView {
this.render();
};

// Every value of the source, in tree order, for "All values"
const values: string[] = [];
walk(tree, (node) => {
if (node.kind === "literal") values.push(stableKey(node));
});
const pickedValues: PickedValue[] = picks.map((p) => ({ node: nodesByKey.get(p.key)!, optional: p.optional }));
const built = pickedValues.length ? buildPatterns(pickedValues, source, queryPrefixes) : undefined;

// One bar on top with the selection and the actions, so they're in reach without scrolling past a long tree
const toolbar = el("div", "yasgui-sa__buildbar");
const allLabel = el("label", "yasgui-sa__all");
const all = el("input", "yasgui-sa__check");
all.type = "checkbox";
all.checked = values.length > 0 && picks.length === values.length;
all.indeterminate = picks.length > 0 && picks.length < values.length;
all.disabled = !values.length;
all.addEventListener("change", () => {
if (!all.checked) return setPicks([]);
// Keep what's picked; like ticking one by one, the first value is required and later ones optional
const next = [...picks];
for (const key of values) {
if (!next.some((p) => p.key === key)) next.push({ key, optional: next.some((p) => !p.optional) });
}
setPicks(next);
});
allLabel.title = picks.length ? "Clear the selection" : "Select all values";
allLabel.append(
all,
el("span", undefined, "All"),
el("span", "yasgui-sa__allCount", `${picks.length}/${values.length}`),
);
toolbar.append(allLabel);

const replace = iconButton("braces", "Replace block", {
className: "yasgui-sa__buildButton primary",
showLabel: true,
title: "Replace the content of the SERVICE block (Ctrl+Z undoes it)",
size: 14,
});
replace.addEventListener("click", () => {
if (!yasqe || !built) return;
const current = yasqe.getSparqlAnythingSources().find((s) => s.key === source.key);
if (!current || current.closeBrace === undefined) return;
const doc = yasqe.getDoc();
const lineStart = doc.posFromIndex(current.from);
const indent = (doc.getLine(lineStart.line).match(/^\s*/) || [""])[0];
const fxPrefix = Object.keys(prefixes).find((p) => prefixes[p] === FX)!;
const used = { ...built.prefixes };
const body: string[] = [];
if (current.optionTriples.length) {
used[fxPrefix] = FX;
body.push(
`${fxPrefix}:properties ` +
current.optionTriples
.map((t) => `${fxPrefix}:${t.name} ${t.raw}`)
.join(` ;\n${indent} ${" ".repeat(fxPrefix.length + 12)}`) +
" .",
);
}
body.push(...built.lines);
const text = "\n" + body.map((line) => `${indent} ${line}`).join("\n") + `\n${indent}`;
doc.replaceRange(text, doc.posFromIndex(current.openBrace + 1), doc.posFromIndex(current.closeBrace));
yasqe.addPrefixes(used);
yasqe.focus();
});
const insert = iconButton("textCursor", "Insert at cursor", {
className: "yasgui-sa__buildButton",
title: "Insert the patterns at the cursor",
size: 15,
});
insert.addEventListener("click", () => {
if (!yasqe || !built) return;
yasqe.replaceSelection(built.lines.join("\n"));
yasqe.addPrefixes(built.prefixes);
yasqe.focus();
});
const newTab = iconButton("externalLink", "New tab", {
className: "yasgui-sa__buildButton",
title: "Open a SELECT query for these values in a new tab",
size: 15,
});
newTab.addEventListener("click", () => {
if (built) this.openInNewTab(tab, buildQuery(pickedValues, source, queryPrefixes));
});
for (const action of [replace, insert, newTab]) action.disabled = !built;
const actions = el("div", "yasgui-sa__buildActions");
actions.append(replace, insert, newTab);
toolbar.append(actions);
wrapper.append(toolbar);
wrapper.append(el("p", "yasgui-sa__hint", "Tick the values you need. Rows and array items become fx:anySlot."));

wrapper.append(
el("p", "yasgui-sa__hint", "Tick the values you need. Rows and array items become fx:anySlot."),
this.drawTree(
tree,
prefixes,
Expand Down Expand Up @@ -534,56 +624,11 @@ export default class SourceView {
),
);

if (!picks.length) return wrapper;
const picked: PickedValue[] = picks.map((p) => ({ node: nodesByKey.get(p.key)!, optional: p.optional }));
const built = buildPatterns(picked, source, queryPrefixes);
const pre = el("pre", "yasgui-sa__code");
pre.textContent = built.lines.join("\n");
wrapper.append(pre);

const actions = el("div", "yasgui-sa__actions");
const replace = button(
"Replace block",
"yasgui-sa__button primary",
"Replace the content of the SERVICE block (Ctrl+Z undoes it)",
);
replace.addEventListener("click", () => {
if (!yasqe) return;
const current = yasqe.getSparqlAnythingSources().find((s) => s.key === source.key);
if (!current || current.closeBrace === undefined) return;
const doc = yasqe.getDoc();
const lineStart = doc.posFromIndex(current.from);
const indent = (doc.getLine(lineStart.line).match(/^\s*/) || [""])[0];
const fxPrefix = Object.keys(prefixes).find((p) => prefixes[p] === FX)!;
const used = { ...built.prefixes };
const body: string[] = [];
if (current.optionTriples.length) {
used[fxPrefix] = FX;
body.push(
`${fxPrefix}:properties ` +
current.optionTriples
.map((t) => `${fxPrefix}:${t.name} ${t.raw}`)
.join(` ;\n${indent} ${" ".repeat(fxPrefix.length + 12)}`) +
" .",
);
}
body.push(...built.lines);
const text = "\n" + body.map((line) => `${indent} ${line}`).join("\n") + `\n${indent}`;
doc.replaceRange(text, doc.posFromIndex(current.openBrace + 1), doc.posFromIndex(current.closeBrace));
yasqe.addPrefixes(used);
yasqe.focus();
});
const insert = button("Insert at cursor", "yasgui-sa__button", "Insert the patterns at the cursor");
insert.addEventListener("click", () => {
if (!yasqe) return;
yasqe.replaceSelection(built.lines.join("\n"));
yasqe.addPrefixes(built.prefixes);
yasqe.focus();
});
const newTab = button("New tab", "yasgui-sa__button", "Open a SELECT query for these values in a new tab");
newTab.addEventListener("click", () => this.openInNewTab(tab, buildQuery(picked, source, queryPrefixes)));
actions.append(replace, insert, newTab);
wrapper.append(actions);
if (built) {
const pre = el("pre", "yasgui-sa__code");
pre.textContent = built.lines.join("\n");
wrapper.append(pre);
}
return wrapper;
}

Expand Down
91 changes: 89 additions & 2 deletions packages/yasgui/src/sparqlAnything/SparqlAnything.scss
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,12 @@
border-color: var(--yasgui-line-strong, #808893);
}

&:disabled {
opacity: 0.5;
cursor: default;
pointer-events: none;
}

&.primary {
border-color: var(--yasgui-brand-fill, #0563bb);
background: var(--yasgui-brand-fill, #0563bb);
Expand Down Expand Up @@ -482,11 +488,92 @@
color: var(--yasgui-ink-muted, #56606c);
}

&__actions {
// The query builder's bar: selection on the left, actions on the right; stays at the top of the scrolling body
&__buildbar {
position: sticky;
z-index: 1;
top: -8px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-top: 10px;
margin: -8px -6px 6px;
padding: 6px 4px 6px 10px;
border-bottom: 1px solid var(--yasgui-line, #dfe5ec);
background: var(--yasgui-surface-raised, #fff);
}

&__all {
display: flex;
align-items: center;
gap: 6px;
color: var(--yasgui-ink-body, #3a4552);
font-size: 12px;
cursor: pointer;
user-select: none;
}

&__allCount {
padding: 0 6px;
border-radius: 999px;
background: var(--yasgui-surface-sunken, #f2f5f8);
color: var(--yasgui-ink-subtle, #6a7380);
font-size: 11px;
font-variant-numeric: tabular-nums;
line-height: 18px;
}

&__buildActions {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
}

&__buildButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 28px;
height: 28px;
padding: 0 6px;
border: 0;
border-radius: var(--yasgui-radius-sm, 6px);
background: transparent;
color: var(--yasgui-ink-muted, #56606c);
font: inherit;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;

.yasgui-btnLabel {
font-size: 12px;
}

&:hover {
background: var(--yasgui-surface-sunken, #f2f5f8);
color: var(--yasgui-ink, #1c2633);
}

&.primary {
margin-right: 2px;
padding: 0 9px 0 7px;
background: var(--yasgui-brand-fill, #0563bb);
color: var(--yasgui-on-brand, #fff);

&:hover {
background: var(--yasgui-brand-fill-strong, #044a8c);
}
}

&:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
}

&__footer {
Expand Down
19 changes: 17 additions & 2 deletions packages/yasgui/src/sparqlAnything/SparqlAnythingSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,10 @@ export default class SparqlAnythingSupport {
label: `${state?.status === "done" ? "Reload" : "Load"} structure of ${source.label}`,
icon: "fileSearch",
keywords: "sparql anything source columns keys autocomplete facade-x",
run: () => void this.load(tab, source),
run: () => {
this.view.show(source);
void this.load(tab, source);
},
});
items.push({
section: "commands",
Expand Down Expand Up @@ -292,6 +295,8 @@ export default class SparqlAnythingSupport {
add(muted("·"));
add(
action("Load structure", "Send a query to the endpoint to read the structure of this source", () => {
// Open the Source view, so the progress and the result show up next to the editor
inspect();
void this.load(tab, source);
}),
);
Expand All @@ -306,7 +311,17 @@ export default class SparqlAnythingSupport {
add(iconElement("alert", 13));
add(name);
add(muted("· could not load"));
add(action("Retry", state.error || "", () => void this.load(tab, source), "danger"));
add(
action(
"Retry",
state.error || "",
() => {
inspect();
void this.load(tab, source);
},
"danger",
),
);
chip.title = state.error || "";
} else {
chip.classList.add("loaded");
Expand Down
Loading
Loading