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
18 changes: 18 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,8 @@ MatGUI includes standard prefixes like:
2. Open Settings (gear icon in the top bar) to manage your prefix collection
3. Enable/disable auto-capture in Settings

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.

**Auto-completion:**
When typing a prefix declaration, Matgui queries [prefix.cc](https://prefix.cc) to suggest standard URIs:

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

**Selection from the Query:**

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:

```sparql
#defaultView:Geo
PREFIX geo: <http://www.opengis.net/ont/geosparql#>
SELECT ?station ?wkt WHERE { ?station geo:hasGeometry/geo:asWKT ?wkt }
```

- Use a plugin name (`Table`, `Geo`, `Graph`, `Chart`, `Response`); the casing doesn't matter
- Wikidata view names work too: `Map` opens the Geo plugin, and `LineChart`, `BarChart`, `ScatterChart`, `AreaChart` and `BubbleChart` open the Chart plugin
- Each run applies the comment again, so it wins over the plugin you picked by hand for that tab
- 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
- Inserting prefixes keeps the comment on the first line

---

### Describing a Resource
Expand Down
2 changes: 2 additions & 0 deletions packages/yasgui/src/Tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1636,6 +1636,8 @@ export class Tab extends EventEmitter {
handleQueryResponse = (_yasqe: Yasqe, response: any, duration: number) => {
this.emit("queryResponse", this);
if (!this.yasr) throw new Error("Resultset visualizer not initialized. Cannot draw results");
// A `#defaultView:<plugin>` comment in the query picks the result view
this.yasr.selectPluginFromQuery(this.yasqe?.getValue());
this.yasr.setResponse(response, duration);
if (!this.yasr.results) return;
if (!this.yasr.results.hasError()) {
Expand Down
40 changes: 12 additions & 28 deletions packages/yasgui/src/TabSettingsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1735,37 +1735,21 @@ export default class TabSettingsModal {
const savedPrefixes = this.tab.yasgui.persistentConfig.getPrefixes();
if (!savedPrefixes.trim()) return;

// Get current query and find where PREFIX declarations end
const currentQuery = yasqe.getValue();
const lines = currentQuery.split("\n");

let firstNonPrefixLine = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
// Skip empty lines and PREFIX lines
if (trimmed.length > 0 && !trimmed.toUpperCase().startsWith("PREFIX")) {
firstNonPrefixLine = i;
break;
}
}
const lines = yasqe.getValue().split("\n");

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

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

// Build new query with saved prefixes + rest of query
const newQuery = savedPrefixes + (restOfQuery ? "\n\n" + restOfQuery : "");
const header = leadingComments.length ? leadingComments.join("\n") + "\n" : "";
const newQuery = header + savedPrefixes + (restOfQuery ? "\n\n" + restOfQuery : "");
yasqe.setValue(newQuery);
yasqe.focus();
}
Expand Down
13 changes: 12 additions & 1 deletion packages/yasqe/src/prefixUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,23 @@ export function addPrefixes(yasqe: Yasqe, prefixes: string | Prefixes) {

export function addPrefixAsString(yasqe: Yasqe, prefixString: string) {
yasqe.getDoc().replaceRange("PREFIX " + prefixString + "\n", {
line: 0,
line: getFirstLineAfterLeadingComments(yasqe),
ch: 0,
});

yasqe.collapsePrefixes(false);
}
/**
* The first line below the comment lines the query starts with, so that added prefixes
* don't push a leading comment such as `#defaultView:Map` down.
*/
export function getFirstLineAfterLeadingComments(yasqe: Yasqe): number {
const doc = yasqe.getDoc();
let line = 0;
while (line <= doc.lastLine() && doc.getLine(line).trim().startsWith("#")) line++;
// A query that is nothing but comments gets its prefixes at the top
return line > doc.lastLine() ? 0 : line;
}
export function removePrefixes(yasqe: Yasqe, prefixes: Prefixes) {
var escapeRegex = function (string: string) {
//taken from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript/3561711#3561711
Expand Down
56 changes: 56 additions & 0 deletions packages/yasr/src/defaultView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Picking a result view from a query comment, as the Wikidata Query Service does:
*
* #defaultView:Map
* SELECT ?item ?geo WHERE { ... }
*
* The comment may be on any full comment line of the query. The keyword is
* case-insensitive and whitespace around the colon is allowed.
*/

const DEFAULT_VIEW_REGEX = /^\s*#\s*defaultView\s*:\s*([A-Za-z][\w-]*)/i;

/**
* Wikidata Query Service view names that map onto a MatGUI plugin with another name.
* Keys are lower case.
*/
const VIEW_ALIASES: { [view: string]: string } = {
map: "Geo",
linechart: "Chart",
barchart: "Chart",
scatterchart: "Chart",
areachart: "Chart",
bubblechart: "Chart",
chart: "Chart",
graph: "Graph",
table: "Table",
};

/** The view named by the first `#defaultView:<name>` comment line in `query`, if any. */
export function getDefaultViewFromQuery(query: string | undefined): string | undefined {
if (!query) return undefined;
for (const line of query.split(/\r?\n/)) {
const match = DEFAULT_VIEW_REGEX.exec(line);
if (match) return match[1];
}
return undefined;
}

/**
* Resolve a view name to one of the available plugin names: an exact (case-insensitive)
* plugin name first, then a plugin label, then a Wikidata view alias.
*/
export function resolveViewToPlugin(
view: string,
plugins: { [name: string]: { label?: string } | undefined },
): string | undefined {
const wanted = view.toLowerCase();
const names = Object.keys(plugins).filter((name) => !!plugins[name]);
const byName = names.find((name) => name.toLowerCase() === wanted);
if (byName) return byName;
const byLabel = names.find((name) => plugins[name]?.label?.toLowerCase() === wanted);
if (byLabel) return byLabel;
const alias = VIEW_ALIASES[wanted];
if (alias && names.indexOf(alias) >= 0) return alias;
return undefined;
}
26 changes: 26 additions & 0 deletions packages/yasr/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { DescribePanel } from "./describe/DescribePanel";
import type { DescribeOptions } from "./describe/DescribePanel";
import type { DescribeDirection } from "./describe/resourceQuery";
import { icon, type IconName } from "./describe/icons";
import { getDefaultViewFromQuery, resolveViewToPlugin } from "./defaultView";
export { getDefaultViewFromQuery, resolveViewToPlugin } from "./defaultView";

import "./main.scss";
import "./scss/tokens.scss";
Expand Down Expand Up @@ -355,6 +357,30 @@ export class Yasr extends EventEmitter {
this.updatePluginSelectors();
this.draw();
}

/**
* Select the plugin that a `#defaultView:<name>` comment in `query` asks for, without
* drawing: call it before [[setResponse]] so the next draw uses that plugin. When the
* plugin can't render the results, yasr falls back as usual.
*
* @returns the selected plugin name, or `undefined` when the query names no known view.
*/
public selectPluginFromQuery(query: string | undefined): string | undefined {
const view = getDefaultViewFromQuery(query);
if (!view) return undefined;
const pluginName = resolveViewToPlugin(view, this.plugins);
if (!pluginName) {
console.warn(`Query asks for view "${view}", but no matching plugin is registered in yasr.`);
return undefined;
}
if (this.selectedPlugin !== pluginName) {
this.selectedPlugin = pluginName;
this.storeConfig();
this.emit("change", this);
this.updatePluginSelectors();
}
return pluginName;
}
private pluginSelectorsEl!: HTMLUListElement;
drawPluginSelectors() {
this.pluginSelectorsEl = document.createElement("ul");
Expand Down
22 changes: 22 additions & 0 deletions test/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,9 @@ PREFIX geo: <http://www.opengis.net/ont/geosparql#> select
polling: 10,
},
);
// The prefix goes below the leading comment line, not above it
const value = await page.evaluate(() => window.yasqe.getValue());
expect(value.startsWith("# prefix #\nPREFIX foaf: <http://xmlns.com/foaf/0.1/>\n")).to.equal(true);
});
it("should show prefix completions after adding new prefix", async () => {
await page.evaluate(() => {
Expand Down Expand Up @@ -1015,4 +1018,23 @@ describe("Yasr", function () {
expect(result.restoredResults).to.equal(false);
expect(result.stillStoredAfterDisable).to.equal(false);
});

it("selects the plugin named by a #defaultView comment in the query", async function () {
const result = await page.evaluate(() => {
const YasrCtor = (window as any).Yasr;
const parent = document.createElement("div");
document.body.appendChild(parent);
const yasr = new YasrCtor(parent, { persistenceId: null });
const picked = yasr.selectPluginFromQuery("#defaultView:Response\nSELECT * WHERE { ?s ?p ?o }");
const unknown = yasr.selectPluginFromQuery("#defaultView:Timeline\nSELECT * WHERE { ?s ?p ?o }");
const none = yasr.selectPluginFromQuery("SELECT * WHERE { ?s ?p ?o }");
yasr.setResponse({ head: { vars: ["s"] }, results: { bindings: [{ s: { type: "uri", value: "urn:a" } }] } });
return { picked, unknown, none, selected: yasr.getSelectedPluginName() };
});

expect(result.picked).to.equal("response");
expect(result.unknown).to.equal(undefined);
expect(result.none).to.equal(undefined);
expect(result.selected).to.equal("response");
});
});
63 changes: 63 additions & 0 deletions test/unit/yasr-default-view-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as chai from "chai";
import { describe, it } from "mocha";

import { getDefaultViewFromQuery, resolveViewToPlugin } from "../../packages/yasr/src/defaultView.js";

const expect = chai.expect;

const PLUGINS = {
Table: { label: "Table" },
Geo: { label: "Geo" },
Graph: { label: "Graph" },
Chart: { label: "Chart" },
response: { label: "Response" },
};

describe("Yasr default view from a query comment", () => {
describe("getDefaultViewFromQuery", () => {
it("reads the view from a leading comment", () => {
expect(getDefaultViewFromQuery("#defaultView:Map\nSELECT * WHERE { ?s ?p ?o }")).to.equal("Map");
});

it("allows whitespace and any keyword casing", () => {
expect(getDefaultViewFromQuery(" # defaultview : Graph\nCONSTRUCT WHERE { ?s ?p ?o }")).to.equal("Graph");
});

it("finds the comment on a later line", () => {
const query = "PREFIX ex: <http://example.org/>\n# a title\n#defaultView:Chart\nSELECT * WHERE { ?s ?p ?o }";
expect(getDefaultViewFromQuery(query)).to.equal("Chart");
});

it("ignores the keyword outside a comment line", () => {
const query = 'SELECT * WHERE { ?s ?p "#defaultView:Map" }';
expect(getDefaultViewFromQuery(query)).to.equal(undefined);
});

it("returns undefined without a comment or query", () => {
expect(getDefaultViewFromQuery("SELECT * WHERE { ?s ?p ?o }")).to.equal(undefined);
expect(getDefaultViewFromQuery(undefined)).to.equal(undefined);
});
});

describe("resolveViewToPlugin", () => {
it("matches a plugin name case-insensitively", () => {
expect(resolveViewToPlugin("geo", PLUGINS)).to.equal("Geo");
expect(resolveViewToPlugin("RESPONSE", PLUGINS)).to.equal("response");
});

it("matches a plugin label", () => {
expect(resolveViewToPlugin("Kaart", { Geo: { label: "Kaart" } })).to.equal("Geo");
});

it("maps Wikidata Query Service view names", () => {
expect(resolveViewToPlugin("Map", PLUGINS)).to.equal("Geo");
expect(resolveViewToPlugin("BarChart", PLUGINS)).to.equal("Chart");
expect(resolveViewToPlugin("LineChart", PLUGINS)).to.equal("Chart");
});

it("returns undefined for an unknown view or a plugin that isn't available", () => {
expect(resolveViewToPlugin("Timeline", PLUGINS)).to.equal(undefined);
expect(resolveViewToPlugin("Map", { Table: { label: "Table" } })).to.equal(undefined);
});
});
});
Loading