Skip to content

Commit 01c8871

Browse files
committed
feat(yasr): pick the result view with a #defaultView query comment
A '#defaultView:<plugin>' comment line in the query selects the plugin the results open in, like the Wikidata Query Service. Plugin names and labels match case-insensitively, and Wikidata view names (Map, BarChart, ...) map onto the Geo and Chart plugins. Added prefixes now go below the comment lines a query starts with, both for autocompleted prefixes and for the Prefixes button, so the comment stays on the first line. Closes #134
1 parent 7d2b770 commit 01c8871

8 files changed

Lines changed: 211 additions & 29 deletions

File tree

‎docs/user-guide.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,8 @@ MatGUI includes standard prefixes like:
699699
2. Open Settings (gear icon in the top bar) to manage your prefix collection
700700
3. Enable/disable auto-capture in Settings
701701

702+
Prefixes are added below the comment lines a query starts with, so a leading comment such as `#defaultView:Geo` (see [Plugin Selection](#plugin-selection)) stays on top.
703+
702704
**Auto-completion:**
703705
When typing a prefix declaration, Matgui queries [prefix.cc](https://prefix.cc) to suggest standard URIs:
704706

@@ -2074,6 +2076,22 @@ Matgui automatically selects the most appropriate plugin based on:
20742076
- Available plugins depend on the query results
20752077
- Your selection is saved per tab
20762078

2079+
**Selection from the Query:**
2080+
2081+
Start the query with a `#defaultView:` comment to pick the view its results open in, the same way the [Wikidata Query Service](https://www.wikidata.org/wiki/Wikidata:SPARQL_query_service/Wikidata_Query_Help/Result_Views) does:
2082+
2083+
```sparql
2084+
#defaultView:Geo
2085+
PREFIX geo: <http://www.opengis.net/ont/geosparql#>
2086+
SELECT ?station ?wkt WHERE { ?station geo:hasGeometry/geo:asWKT ?wkt }
2087+
```
2088+
2089+
- Use a plugin name (`Table`, `Geo`, `Graph`, `Chart`, `Response`); the casing doesn't matter
2090+
- Wikidata view names work too: `Map` opens the Geo plugin, and `LineChart`, `BarChart`, `ScatterChart`, `AreaChart` and `BubbleChart` open the Chart plugin
2091+
- Each run applies the comment again, so it wins over the plugin you picked by hand for that tab
2092+
- When the plugin can't show the results (for example `#defaultView:Geo` on a query without geometries), MatGUI falls back to a plugin that can, as usual
2093+
- Inserting prefixes keeps the comment on the first line
2094+
20772095
---
20782096

20792097
### Describing a Resource

‎packages/yasgui/src/Tab.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1636,6 +1636,8 @@ export class Tab extends EventEmitter {
16361636
handleQueryResponse = (_yasqe: Yasqe, response: any, duration: number) => {
16371637
this.emit("queryResponse", this);
16381638
if (!this.yasr) throw new Error("Resultset visualizer not initialized. Cannot draw results");
1639+
// A `#defaultView:<plugin>` comment in the query picks the result view
1640+
this.yasr.selectPluginFromQuery(this.yasqe?.getValue());
16391641
this.yasr.setResponse(response, duration);
16401642
if (!this.yasr.results) return;
16411643
if (!this.yasr.results.hasError()) {

‎packages/yasgui/src/TabSettingsModal.ts‎

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1735,37 +1735,21 @@ export default class TabSettingsModal {
17351735
const savedPrefixes = this.tab.yasgui.persistentConfig.getPrefixes();
17361736
if (!savedPrefixes.trim()) return;
17371737

1738-
// Get current query and find where PREFIX declarations end
1739-
const currentQuery = yasqe.getValue();
1740-
const lines = currentQuery.split("\n");
1741-
1742-
let firstNonPrefixLine = 0;
1743-
for (let i = 0; i < lines.length; i++) {
1744-
const trimmed = lines[i].trim();
1745-
// Skip empty lines and PREFIX lines
1746-
if (trimmed.length > 0 && !trimmed.toUpperCase().startsWith("PREFIX")) {
1747-
firstNonPrefixLine = i;
1748-
break;
1749-
}
1750-
}
1738+
const lines = yasqe.getValue().split("\n");
17511739

1752-
// If we didn't find a non-PREFIX line, all lines are PREFIX or empty
1753-
if (firstNonPrefixLine === 0 && lines.length > 0) {
1754-
// Check if there's any content at all
1755-
const hasContent = lines.some((line: string) => line.trim().length > 0);
1756-
if (
1757-
!hasContent ||
1758-
lines.every((line: string) => line.trim().length === 0 || line.trim().toUpperCase().startsWith("PREFIX"))
1759-
) {
1760-
firstNonPrefixLine = lines.length;
1761-
}
1762-
}
1740+
// Keep leading comment lines (such as `#defaultView:Map`) above the prefixes
1741+
let i = 0;
1742+
const leadingComments: string[] = [];
1743+
while (i < lines.length && lines[i].trim().startsWith("#")) leadingComments.push(lines[i++]);
17631744

1764-
// Keep the rest of the query (non-PREFIX lines)
1765-
const restOfQuery = lines.slice(firstNonPrefixLine).join("\n").trim();
1745+
// Drop the existing PREFIX declarations (and blank lines); the saved prefixes replace them
1746+
while (i < lines.length && (lines[i].trim().length === 0 || lines[i].trim().toUpperCase().startsWith("PREFIX"))) {
1747+
i++;
1748+
}
1749+
const restOfQuery = lines.slice(i).join("\n").trim();
17661750

1767-
// Build new query with saved prefixes + rest of query
1768-
const newQuery = savedPrefixes + (restOfQuery ? "\n\n" + restOfQuery : "");
1751+
const header = leadingComments.length ? leadingComments.join("\n") + "\n" : "";
1752+
const newQuery = header + savedPrefixes + (restOfQuery ? "\n\n" + restOfQuery : "");
17691753
yasqe.setValue(newQuery);
17701754
yasqe.focus();
17711755
}

‎packages/yasqe/src/prefixUtils.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,23 @@ export function addPrefixes(yasqe: Yasqe, prefixes: string | Prefixes) {
1515

1616
export function addPrefixAsString(yasqe: Yasqe, prefixString: string) {
1717
yasqe.getDoc().replaceRange("PREFIX " + prefixString + "\n", {
18-
line: 0,
18+
line: getFirstLineAfterLeadingComments(yasqe),
1919
ch: 0,
2020
});
2121

2222
yasqe.collapsePrefixes(false);
2323
}
24+
/**
25+
* The first line below the comment lines the query starts with, so that added prefixes
26+
* don't push a leading comment such as `#defaultView:Map` down.
27+
*/
28+
export function getFirstLineAfterLeadingComments(yasqe: Yasqe): number {
29+
const doc = yasqe.getDoc();
30+
let line = 0;
31+
while (line <= doc.lastLine() && doc.getLine(line).trim().startsWith("#")) line++;
32+
// A query that is nothing but comments gets its prefixes at the top
33+
return line > doc.lastLine() ? 0 : line;
34+
}
2435
export function removePrefixes(yasqe: Yasqe, prefixes: Prefixes) {
2536
var escapeRegex = function (string: string) {
2637
//taken from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript/3561711#3561711

‎packages/yasr/src/defaultView.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Picking a result view from a query comment, as the Wikidata Query Service does:
3+
*
4+
* #defaultView:Map
5+
* SELECT ?item ?geo WHERE { ... }
6+
*
7+
* The comment may be on any full comment line of the query. The keyword is
8+
* case-insensitive and whitespace around the colon is allowed.
9+
*/
10+
11+
const DEFAULT_VIEW_REGEX = /^\s*#\s*defaultView\s*:\s*([A-Za-z][\w-]*)/i;
12+
13+
/**
14+
* Wikidata Query Service view names that map onto a MatGUI plugin with another name.
15+
* Keys are lower case.
16+
*/
17+
const VIEW_ALIASES: { [view: string]: string } = {
18+
map: "Geo",
19+
linechart: "Chart",
20+
barchart: "Chart",
21+
scatterchart: "Chart",
22+
areachart: "Chart",
23+
bubblechart: "Chart",
24+
chart: "Chart",
25+
graph: "Graph",
26+
table: "Table",
27+
};
28+
29+
/** The view named by the first `#defaultView:<name>` comment line in `query`, if any. */
30+
export function getDefaultViewFromQuery(query: string | undefined): string | undefined {
31+
if (!query) return undefined;
32+
for (const line of query.split(/\r?\n/)) {
33+
const match = DEFAULT_VIEW_REGEX.exec(line);
34+
if (match) return match[1];
35+
}
36+
return undefined;
37+
}
38+
39+
/**
40+
* Resolve a view name to one of the available plugin names: an exact (case-insensitive)
41+
* plugin name first, then a plugin label, then a Wikidata view alias.
42+
*/
43+
export function resolveViewToPlugin(
44+
view: string,
45+
plugins: { [name: string]: { label?: string } | undefined },
46+
): string | undefined {
47+
const wanted = view.toLowerCase();
48+
const names = Object.keys(plugins).filter((name) => !!plugins[name]);
49+
const byName = names.find((name) => name.toLowerCase() === wanted);
50+
if (byName) return byName;
51+
const byLabel = names.find((name) => plugins[name]?.label?.toLowerCase() === wanted);
52+
if (byLabel) return byLabel;
53+
const alias = VIEW_ALIASES[wanted];
54+
if (alias && names.indexOf(alias) >= 0) return alias;
55+
return undefined;
56+
}

‎packages/yasr/src/index.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { DescribePanel } from "./describe/DescribePanel";
1010
import type { DescribeOptions } from "./describe/DescribePanel";
1111
import type { DescribeDirection } from "./describe/resourceQuery";
1212
import { icon, type IconName } from "./describe/icons";
13+
import { getDefaultViewFromQuery, resolveViewToPlugin } from "./defaultView";
14+
export { getDefaultViewFromQuery, resolveViewToPlugin } from "./defaultView";
1315

1416
import "./main.scss";
1517
import "./scss/tokens.scss";
@@ -355,6 +357,30 @@ export class Yasr extends EventEmitter {
355357
this.updatePluginSelectors();
356358
this.draw();
357359
}
360+
361+
/**
362+
* Select the plugin that a `#defaultView:<name>` comment in `query` asks for, without
363+
* drawing: call it before [[setResponse]] so the next draw uses that plugin. When the
364+
* plugin can't render the results, yasr falls back as usual.
365+
*
366+
* @returns the selected plugin name, or `undefined` when the query names no known view.
367+
*/
368+
public selectPluginFromQuery(query: string | undefined): string | undefined {
369+
const view = getDefaultViewFromQuery(query);
370+
if (!view) return undefined;
371+
const pluginName = resolveViewToPlugin(view, this.plugins);
372+
if (!pluginName) {
373+
console.warn(`Query asks for view "${view}", but no matching plugin is registered in yasr.`);
374+
return undefined;
375+
}
376+
if (this.selectedPlugin !== pluginName) {
377+
this.selectedPlugin = pluginName;
378+
this.storeConfig();
379+
this.emit("change", this);
380+
this.updatePluginSelectors();
381+
}
382+
return pluginName;
383+
}
358384
private pluginSelectorsEl!: HTMLUListElement;
359385
drawPluginSelectors() {
360386
this.pluginSelectorsEl = document.createElement("ul");

‎test/run.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,9 @@ PREFIX geo: <http://www.opengis.net/ont/geosparql#> select
301301
polling: 10,
302302
},
303303
);
304+
// The prefix goes below the leading comment line, not above it
305+
const value = await page.evaluate(() => window.yasqe.getValue());
306+
expect(value.startsWith("# prefix #\nPREFIX foaf: <http://xmlns.com/foaf/0.1/>\n")).to.equal(true);
304307
});
305308
it("should show prefix completions after adding new prefix", async () => {
306309
await page.evaluate(() => {
@@ -1015,4 +1018,23 @@ describe("Yasr", function () {
10151018
expect(result.restoredResults).to.equal(false);
10161019
expect(result.stillStoredAfterDisable).to.equal(false);
10171020
});
1021+
1022+
it("selects the plugin named by a #defaultView comment in the query", async function () {
1023+
const result = await page.evaluate(() => {
1024+
const YasrCtor = (window as any).Yasr;
1025+
const parent = document.createElement("div");
1026+
document.body.appendChild(parent);
1027+
const yasr = new YasrCtor(parent, { persistenceId: null });
1028+
const picked = yasr.selectPluginFromQuery("#defaultView:Response\nSELECT * WHERE { ?s ?p ?o }");
1029+
const unknown = yasr.selectPluginFromQuery("#defaultView:Timeline\nSELECT * WHERE { ?s ?p ?o }");
1030+
const none = yasr.selectPluginFromQuery("SELECT * WHERE { ?s ?p ?o }");
1031+
yasr.setResponse({ head: { vars: ["s"] }, results: { bindings: [{ s: { type: "uri", value: "urn:a" } }] } });
1032+
return { picked, unknown, none, selected: yasr.getSelectedPluginName() };
1033+
});
1034+
1035+
expect(result.picked).to.equal("response");
1036+
expect(result.unknown).to.equal(undefined);
1037+
expect(result.none).to.equal(undefined);
1038+
expect(result.selected).to.equal("response");
1039+
});
10181040
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as chai from "chai";
2+
import { describe, it } from "mocha";
3+
4+
import { getDefaultViewFromQuery, resolveViewToPlugin } from "../../packages/yasr/src/defaultView.js";
5+
6+
const expect = chai.expect;
7+
8+
const PLUGINS = {
9+
Table: { label: "Table" },
10+
Geo: { label: "Geo" },
11+
Graph: { label: "Graph" },
12+
Chart: { label: "Chart" },
13+
response: { label: "Response" },
14+
};
15+
16+
describe("Yasr default view from a query comment", () => {
17+
describe("getDefaultViewFromQuery", () => {
18+
it("reads the view from a leading comment", () => {
19+
expect(getDefaultViewFromQuery("#defaultView:Map\nSELECT * WHERE { ?s ?p ?o }")).to.equal("Map");
20+
});
21+
22+
it("allows whitespace and any keyword casing", () => {
23+
expect(getDefaultViewFromQuery(" # defaultview : Graph\nCONSTRUCT WHERE { ?s ?p ?o }")).to.equal("Graph");
24+
});
25+
26+
it("finds the comment on a later line", () => {
27+
const query = "PREFIX ex: <http://example.org/>\n# a title\n#defaultView:Chart\nSELECT * WHERE { ?s ?p ?o }";
28+
expect(getDefaultViewFromQuery(query)).to.equal("Chart");
29+
});
30+
31+
it("ignores the keyword outside a comment line", () => {
32+
const query = 'SELECT * WHERE { ?s ?p "#defaultView:Map" }';
33+
expect(getDefaultViewFromQuery(query)).to.equal(undefined);
34+
});
35+
36+
it("returns undefined without a comment or query", () => {
37+
expect(getDefaultViewFromQuery("SELECT * WHERE { ?s ?p ?o }")).to.equal(undefined);
38+
expect(getDefaultViewFromQuery(undefined)).to.equal(undefined);
39+
});
40+
});
41+
42+
describe("resolveViewToPlugin", () => {
43+
it("matches a plugin name case-insensitively", () => {
44+
expect(resolveViewToPlugin("geo", PLUGINS)).to.equal("Geo");
45+
expect(resolveViewToPlugin("RESPONSE", PLUGINS)).to.equal("response");
46+
});
47+
48+
it("matches a plugin label", () => {
49+
expect(resolveViewToPlugin("Kaart", { Geo: { label: "Kaart" } })).to.equal("Geo");
50+
});
51+
52+
it("maps Wikidata Query Service view names", () => {
53+
expect(resolveViewToPlugin("Map", PLUGINS)).to.equal("Geo");
54+
expect(resolveViewToPlugin("BarChart", PLUGINS)).to.equal("Chart");
55+
expect(resolveViewToPlugin("LineChart", PLUGINS)).to.equal("Chart");
56+
});
57+
58+
it("returns undefined for an unknown view or a plugin that isn't available", () => {
59+
expect(resolveViewToPlugin("Timeline", PLUGINS)).to.equal(undefined);
60+
expect(resolveViewToPlugin("Map", { Table: { label: "Table" } })).to.equal(undefined);
61+
});
62+
});
63+
});

0 commit comments

Comments
 (0)