diff --git a/docs/user-guide.md b/docs/user-guide.md index 5e4634ee..e432b264 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -712,19 +712,19 @@ PREFIX rdfs: """ ; ### URI Explorer -Quickly explore RDF resources by Ctrl+clicking on URIs in your query. +Quickly explore RDF resources by Ctrl+(Shift+)clicking on URIs in your query. **How It Works:** 1. Hold `Ctrl` and click any URI in the query editor -2. Matgui automatically generates and executes a CONSTRUCT query exploring: - - Outgoing triples (where the URI is the subject) - - Incoming triples (where the URI is the object) +2. MatGUI automatically generates and executes a CONSTRUCT query where the URI is the **subject** 3. Results appear without modifying your original query 4. View the resource's connections in the results viewer +Alternatively, use `Ctrl`+`Shift` and click on the URI to executes a CONSTRUCT query where the URI is the **object**. + **Example:** -Ctrl+clicking on `http://dbpedia.org/resource/European_Union` automatically queries for all triples related to the European Union. +Ctrl+clicking on `http://dbpedia.org/resource/European_Union` automatically queries for all triples where the European Union is the subject of. ### Query Tabs @@ -1396,7 +1396,8 @@ Shows the raw response from the SPARQL endpoint. - Code folding for nested structures - Copy to clipboard functionality - Line numbers -- **Ctrl+Click on a URI** to run a `DESCRIBE` query for that resource and append the returned triples to the response view (similar to the Graph plugin's node expansion) +- **Ctrl+Click on a URI** executes a CONSTRUCT query where the URI is the **subject** and append the returned triples to the response view (similar to the Graph plugin's node expansion) +- **Ctrl+Click on a URI** executes a CONSTRUCT query where the URI is the **object** and append the returned triples to the response view (similar to the Graph plugin's node expansion) **Best For:** @@ -1800,17 +1801,18 @@ Master Matgui with these keyboard shortcuts for faster querying. ### Query Editor (YASQE) -| Shortcut | Action | -| -------------------------- | -------------------------------- | -| `Ctrl+Enter` / `Cmd+Enter` | Execute query | -| `Ctrl+Space` | Trigger autocomplete | -| `Ctrl+S` | Save query to local storage | -| `Ctrl+Shift+F` | Format query | -| `Ctrl+/` | Toggle comment on selected lines | -| `Ctrl+Shift+D` | Duplicate current line | -| `Ctrl+Shift+K` | Delete current line | -| `Esc` | Remove focus from editor | -| `Ctrl+Click` (on URI) | Explore URI connections | +| Shortcut | Action | +| --------------------------- | -------------------------------- | +| `Ctrl+Enter` / `Cmd+Enter` | Execute query | +| `Ctrl+Space` | Trigger autocomplete | +| `Ctrl+S` | Save query to local storage | +| `Ctrl+Shift+F` | Format query | +| `Ctrl+/` | Toggle comment on selected lines | +| `Ctrl+Shift+D` | Duplicate current line | +| `Ctrl+Shift+K` | Delete current line | +| `Esc` | Remove focus from editor | +| `Ctrl+Click` (on URI) | Explore outgoing URI connections | +| `Ctrl+Shift+Click` (on URI) | Explore incoming URI connections | ### Fullscreen diff --git a/packages/yasgui/src/Tab.ts b/packages/yasgui/src/Tab.ts index 7de2d106..7bb54002 100644 --- a/packages/yasgui/src/Tab.ts +++ b/packages/yasgui/src/Tab.ts @@ -9,6 +9,8 @@ import { Config as YasrConfig, PersistentConfig as YasrPersistentConfig, PluginQueryOptions as YasrPluginQueryOptions, + buildSubjectOfQuery, + buildObjectOfQuery, } from "@matdata/yasr"; import { mapValues, eq, mergeWith, words, deburr, invert } from "lodash-es"; import * as shareLink from "./linkUtils"; @@ -1564,21 +1566,15 @@ export class Tab extends EventEmitter { } } - // Construct the query - const constructQuery = `CONSTRUCT { - ?s_left ?p_left ?target . - ?target ?p_right ?o_right . -} -WHERE { - BIND(<${uri}> as ?target) - { - ?s_left ?p_left ?target . - } - UNION - { - ?target ?p_right ?o_right . - } -} LIMIT 1000`; + // Construct the query based on whether Shift is held + let constructQuery: string; + if (event.shiftKey) { + // Ctrl+Shift+Click: find all triples where URI is the object + constructQuery = buildObjectOfQuery(uri); + } else { + // Ctrl+Click: find all triples where URI is the subject + constructQuery = buildSubjectOfQuery(uri); + } // Execute query in background without changing editor content // Note: void operator is intentional - errors are handled in the catch block of executeBackgroundQuery diff --git a/packages/yasgui/src/TabSettingsModal.ts b/packages/yasgui/src/TabSettingsModal.ts index 19456af6..bd1fa79a 100644 --- a/packages/yasgui/src/TabSettingsModal.ts +++ b/packages/yasgui/src/TabSettingsModal.ts @@ -2032,7 +2032,8 @@ export default class TabSettingsModal { { keys: ["Ctrl+Shift+D", "Cmd+Shift+D"], description: "Duplicate current line" }, { keys: ["Ctrl+Shift+K", "Cmd+Shift+K"], description: "Delete current line" }, { keys: ["Esc"], description: "Remove focus from editor" }, - { keys: ["Ctrl+Click"], description: "Explore URI connections (on URI)" }, + { keys: ["Ctrl+Click"], description: "Find triples where URI is subject" }, + { keys: ["Ctrl+Shift+Click"], description: "Find triples where URI is object" }, ], }, { diff --git a/packages/yasqe/src/index.ts b/packages/yasqe/src/index.ts index 84479da7..8bf71602 100644 --- a/packages/yasqe/src/index.ts +++ b/packages/yasqe/src/index.ts @@ -228,7 +228,10 @@ export class Yasqe extends EditorFacade { if (token && token.type === "variable-3") { // Token type "variable-3" represents URIs (IRI_REF) - this.showNotification("uri-describe-hint", "Hold CTRL - left mouse click on URI to DESCRIBE"); + this.showNotification( + "uri-describe-hint", + "CTRL+click: find triples where URI is subject. CTRL+SHIFT+click: find triples where URI is object.", + ); } else { this.hideNotification("uri-describe-hint"); } diff --git a/packages/yasr/src/index.ts b/packages/yasr/src/index.ts index b2ec84fb..95385dc0 100644 --- a/packages/yasr/src/index.ts +++ b/packages/yasr/src/index.ts @@ -792,5 +792,6 @@ Yasr.registerPlugin("response", YasrPluginResponse.default as any); Yasr.registerPlugin("error", YasrPluginError.default as any); export type { Plugin, DownloadInfo } from "./plugins"; +export { buildSubjectOfQuery, buildObjectOfQuery } from "./plugins/response/uriUtils"; export default Yasr; diff --git a/packages/yasr/src/plugins/response/index.ts b/packages/yasr/src/plugins/response/index.ts index 42cf546c..b60b24b4 100644 --- a/packages/yasr/src/plugins/response/index.ts +++ b/packages/yasr/src/plugins/response/index.ts @@ -14,7 +14,12 @@ import { turtle } from "codemirror-lang-turtle"; import { javascript } from "@codemirror/legacy-modes/mode/javascript"; import { addClass, removeClass } from "@matdata/yasgui-utils"; import { DeepReadonly } from "ts-essentials"; -import { extractUriAtOffset, buildDescribeQuery } from "./uriUtils"; +import { + extractUriAtOffset, + buildSubjectOfQuery, + buildObjectOfQuery, + stripDuplicatePrefixDeclarations, +} from "./uriUtils"; export interface PluginConfig { maxLines: number; @@ -27,7 +32,7 @@ export default class Response implements Plugin { private config: DeepReadonly; private overLay: HTMLDivElement | undefined; private cm: EditorView | undefined; - /** Turtle appended to the response view through Ctrl+Click DESCRIBE actions. */ + /** Turtle appended to the response view through Ctrl+Click URI exploration actions. */ private appendedContent = ""; constructor(yasr: Yasr) { this.yasr = yasr; @@ -130,8 +135,8 @@ export default class Response implements Plugin { } /** - * Ctrl/Cmd+Click on a URI in the response view runs a `DESCRIBE` query for that - * URI and appends the result to the response view (similar to the graph plugin). + * Ctrl/Cmd+Click on a URI runs a CONSTRUCT query for triples where the URI is the subject. + * Ctrl/Cmd+Shift+Click runs a CONSTRUCT query for triples where the URI is the object. */ private handleMouseDown = (event: MouseEvent) => { if (!event.ctrlKey && !event.metaKey) return; @@ -148,42 +153,75 @@ export default class Response implements Plugin { event.preventDefault(); event.stopPropagation(); - void this.describeUri(uri); + + if (event.shiftKey) { + void this.runObjectOfQuery(uri); + } else { + void this.runSubjectOfQuery(uri); + } }; - private async describeUri(uri: string) { + private async runSubjectOfQuery(uri: string) { + if (!this.yasr.config.executeQuery) return; + const cmAtStart = this.cm; + if (!cmAtStart) return; + + const query = buildSubjectOfQuery(uri); + try { + this.yasr.showLoading(); + const response = await this.yasr.executeQuery(query, { acceptHeader: this.getConstructAcceptHeader() }); + if (this.cm !== cmAtStart) return; + + const content = this.getResponseContent(response); + if (content) { + this.appendToView(`\n\n# Triples where <${uri}> is subject\n${content.trim()}\n`); + } else { + this.appendToView(`\n\n# Triples where <${uri}> is subject: no data\n`); + } + } catch (error) { + if (this.cm !== cmAtStart) return; + + console.error("Subject-of query failed:", error); + const message = error instanceof Error ? error.message : String(error); + this.appendToView(`\n\n# Triples where <${uri}> is subject failed: ${message}\n`); + } finally { + if (this.cm === cmAtStart) this.yasr.hideLoading(); + } + } + + private async runObjectOfQuery(uri: string) { if (!this.yasr.config.executeQuery) return; const cmAtStart = this.cm; if (!cmAtStart) return; - const query = buildDescribeQuery(uri); + const query = buildObjectOfQuery(uri); try { this.yasr.showLoading(); - const response = await this.yasr.executeQuery(query, { acceptHeader: this.getDescribeAcceptHeader() }); + const response = await this.yasr.executeQuery(query, { acceptHeader: this.getConstructAcceptHeader() }); if (this.cm !== cmAtStart) return; const content = this.getResponseContent(response); if (content) { - this.appendToView(`\n\n# DESCRIBE <${uri}>\n${content.trim()}\n`); + this.appendToView(`\n\n# Triples where <${uri}> is object\n${content.trim()}\n`); } else { - this.appendToView(`\n\n# DESCRIBE <${uri}> returned no data\n`); + this.appendToView(`\n\n# Triples where <${uri}> is object: no data\n`); } } catch (error) { if (this.cm !== cmAtStart) return; - console.error("DESCRIBE query failed:", error); + console.error("Object-of query failed:", error); const message = error instanceof Error ? error.message : String(error); - this.appendToView(`\n\n# DESCRIBE <${uri}> failed: ${message}\n`); + this.appendToView(`\n\n# Triples where <${uri}> is object failed: ${message}\n`); } finally { if (this.cm === cmAtStart) this.yasr.hideLoading(); } } /** - * Pick an Accept header for the DESCRIBE request. When the current response is an - * RDF graph format, reuse it so the appended triples match what is already shown. + * Pick an Accept header for follow-up CONSTRUCT requests. When the current response + * is an RDF graph format, reuse it so the appended triples match what is shown. */ - private getDescribeAcceptHeader(): string { + private getConstructAcceptHeader(): string { const contentType = this.yasr.results?.getContentType(); if (contentType) { const lower = contentType.toLowerCase(); @@ -211,12 +249,13 @@ export default class Response implements Plugin { } /** - * Append text (a DESCRIBE result or a status message) to the response view and - * make sure the full content is revealed. + * Append text (query result or status message) to the response view and make sure + * the full content is revealed. */ private appendToView(text: string) { if (!this.cm || !text) return; - this.appendedContent += text; + const existingVisibleContent = (this.yasr.results?.getOriginalResponseAsString() || "") + this.appendedContent; + this.appendedContent += stripDuplicatePrefixDeclarations(text, existingVisibleContent); // Reveal the full response together with the appended DESCRIBE results. this.showMore(); } diff --git a/packages/yasr/src/plugins/response/uriUtils.ts b/packages/yasr/src/plugins/response/uriUtils.ts index 6a8651ec..5a72bf5d 100644 --- a/packages/yasr/src/plugins/response/uriUtils.ts +++ b/packages/yasr/src/plugins/response/uriUtils.ts @@ -1,5 +1,5 @@ /** - * Standalone helpers for the Response plugin's "Ctrl+Click a URI to DESCRIBE" feature. + * Standalone helpers for the Response plugin's Ctrl+Click URI exploration feature. * * These functions are intentionally free of any DOM or CodeMirror dependencies so * that they can be unit-tested in isolation. @@ -9,6 +9,8 @@ const ANGLE_IRI_RE = /<([^<>\s"{}|\\^`]+)>/g; // Matches bare or quoted URIs as they appear in JSON / XML / CSV responses, e.g. `"http://example.org/foo"` const BARE_URI_RE = /(?:https?|urn|ftp|mailto):[^\s<>"'`{}|\\^[\]]+/g; +// Matches Turtle/SPARQL prefix declarations such as `@prefix ex: <...> .` and `PREFIX ex: <...>`. +const PREFIX_DECL_RE = /^\s*(?:@prefix|PREFIX)\s+(\w*):\s*<[^>]+>\s*\.?\s*$/i; /** * Remove trailing punctuation that is commonly adjacent to a URI in a serialized @@ -57,11 +59,59 @@ export function extractUriAtOffset(text: string, offset: number): string | undef } /** - * Build a `CONSTRUCT` query for the given URI. + * Build a CONSTRUCT query that retrieves all triples where the given URI is the subject. * - * @param uri The URI to describe. - * @returns A SPARQL `CONSTRUCT` query string with the uri as the subject. + * @param uri The URI to look up as a subject. + * @returns A SPARQL `CONSTRUCT` query string. */ -export function buildDescribeQuery(uri: string): string { +export function buildSubjectOfQuery(uri: string): string { return `CONSTRUCT { <${uri}> ?p ?o } WHERE { <${uri}> ?p ?o }`; } + +/** + * Build a CONSTRUCT query that retrieves all triples where the given URI is the object. + * + * @param uri The URI to look up as an object. + * @returns A SPARQL `CONSTRUCT` query string. + */ +export function buildObjectOfQuery(uri: string): string { + return `CONSTRUCT { ?s ?p <${uri}> } WHERE { ?s ?p <${uri}> } LIMIT 1000`; +} + +/** + * Remove duplicate prefix declarations from a text block. + * + * Prefixes are considered duplicates by label (case-insensitive), and can be + * deduplicated against already-rendered content in the response viewer. + */ +export function stripDuplicatePrefixDeclarations(text: string, existingText = ""): string { + if (!text) return text; + + const seenLabels = new Set(); + const addSeenLabels = (source: string) => { + for (const line of source.split("\n")) { + const match = line.match(PREFIX_DECL_RE); + if (!match) continue; + seenLabels.add(match[1].toLowerCase()); + } + }; + + addSeenLabels(existingText); + + const deduplicatedLines: string[] = []; + for (const line of text.split("\n")) { + const match = line.match(PREFIX_DECL_RE); + if (!match) { + deduplicatedLines.push(line); + continue; + } + + const label = match[1].toLowerCase(); + if (seenLabels.has(label)) continue; + + seenLabels.add(label); + deduplicatedLines.push(line); + } + + return deduplicatedLines.join("\n"); +} diff --git a/test/unit/response-uri-utils-test.ts b/test/unit/response-uri-utils-test.ts index 4a1d0f9b..70577605 100644 --- a/test/unit/response-uri-utils-test.ts +++ b/test/unit/response-uri-utils-test.ts @@ -1,7 +1,12 @@ import * as chai from "chai"; import { describe, it } from "mocha"; -import { extractUriAtOffset, buildDescribeQuery } from "../../packages/yasr/src/plugins/response/uriUtils.js"; +import { + extractUriAtOffset, + buildSubjectOfQuery, + buildObjectOfQuery, + stripDuplicatePrefixDeclarations, +} from "../../packages/yasr/src/plugins/response/uriUtils.js"; const expect = chai.expect; @@ -52,11 +57,43 @@ describe("Response plugin URI utilities", () => { }); }); - describe("buildDescribeQuery", () => { - it("wraps the URI in a DESCRIBE query", () => { - expect(buildDescribeQuery("http://example.org/foo")).to.equal( + describe("buildSubjectOfQuery", () => { + it("wraps the URI in a CONSTRUCT query retrieving triples where the URI is subject", () => { + expect(buildSubjectOfQuery("http://example.org/foo")).to.equal( "CONSTRUCT { ?p ?o } WHERE { ?p ?o }", ); }); }); + + describe("buildObjectOfQuery", () => { + it("wraps the URI in a CONSTRUCT query retrieving triples where the URI is object", () => { + expect(buildObjectOfQuery("http://example.org/foo")).to.equal( + "CONSTRUCT { ?s ?p } WHERE { ?s ?p } LIMIT 1000", + ); + }); + }); + + describe("stripDuplicatePrefixDeclarations", () => { + it("removes appended prefixes that already exist in the current view", () => { + const existing = "@prefix ex: .\n ."; + const appended = + "@prefix ex: .\n@prefix foaf: .\n a foaf:Person ."; + expect(stripDuplicatePrefixDeclarations(appended, existing)).to.equal( + "@prefix foaf: .\n a foaf:Person .", + ); + }); + + it("deduplicates duplicate prefix labels within the appended block", () => { + const appended = + "PREFIX ex: \nPREFIX EX: \n ."; + expect(stripDuplicatePrefixDeclarations(appended)).to.equal( + "PREFIX ex: \n .", + ); + }); + + it("keeps non-prefix lines unchanged", () => { + const appended = "# Triples where is subject\n ."; + expect(stripDuplicatePrefixDeclarations(appended)).to.equal(appended); + }); + }); });