From d444da35017bdd1734d6d92b8f0996a678bf4aaa Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Mon, 19 Oct 2026 16:36:37 +0000 Subject: [PATCH] feat(yasr): pick the result view with a #defaultView query comment A '#defaultView:' 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 --- docs/user-guide.md | 18 +++++++ packages/yasgui/src/Tab.ts | 2 + packages/yasgui/src/TabSettingsModal.ts | 40 +++++----------- packages/yasqe/src/prefixUtils.ts | 13 ++++- packages/yasr/src/defaultView.ts | 56 ++++++++++++++++++++++ packages/yasr/src/index.ts | 26 ++++++++++ test/run.ts | 22 +++++++++ test/unit/yasr-default-view-test.ts | 63 +++++++++++++++++++++++++ 8 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 packages/yasr/src/defaultView.ts create mode 100644 test/unit/yasr-default-view-test.ts diff --git a/docs/user-guide.md b/docs/user-guide.md index ec7a7cb4..2f870f16 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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: @@ -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: +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 diff --git a/packages/yasgui/src/Tab.ts b/packages/yasgui/src/Tab.ts index afaf04be..fd241a33 100644 --- a/packages/yasgui/src/Tab.ts +++ b/packages/yasgui/src/Tab.ts @@ -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:` 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()) { diff --git a/packages/yasgui/src/TabSettingsModal.ts b/packages/yasgui/src/TabSettingsModal.ts index 2c09d729..eef4ace0 100644 --- a/packages/yasgui/src/TabSettingsModal.ts +++ b/packages/yasgui/src/TabSettingsModal.ts @@ -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(); } diff --git a/packages/yasqe/src/prefixUtils.ts b/packages/yasqe/src/prefixUtils.ts index dac66e63..048f6bf2 100644 --- a/packages/yasqe/src/prefixUtils.ts +++ b/packages/yasqe/src/prefixUtils.ts @@ -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 diff --git a/packages/yasr/src/defaultView.ts b/packages/yasr/src/defaultView.ts new file mode 100644 index 00000000..58122684 --- /dev/null +++ b/packages/yasr/src/defaultView.ts @@ -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:` 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; +} diff --git a/packages/yasr/src/index.ts b/packages/yasr/src/index.ts index de7aafcc..ec3d5d42 100644 --- a/packages/yasr/src/index.ts +++ b/packages/yasr/src/index.ts @@ -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"; @@ -355,6 +357,30 @@ export class Yasr extends EventEmitter { this.updatePluginSelectors(); this.draw(); } + + /** + * Select the plugin that a `#defaultView:` 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"); diff --git a/test/run.ts b/test/run.ts index 92fc5da5..ff822de6 100644 --- a/test/run.ts +++ b/test/run.ts @@ -301,6 +301,9 @@ PREFIX geo: 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: \n")).to.equal(true); }); it("should show prefix completions after adding new prefix", async () => { await page.evaluate(() => { @@ -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"); + }); }); diff --git a/test/unit/yasr-default-view-test.ts b/test/unit/yasr-default-view-test.ts new file mode 100644 index 00000000..248453c2 --- /dev/null +++ b/test/unit/yasr-default-view-test.ts @@ -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: \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); + }); + }); +});