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
36 changes: 19 additions & 17 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -712,19 +712,19 @@ PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>""" ;

### 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

Expand Down Expand Up @@ -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:**

Expand Down Expand Up @@ -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

Expand Down
26 changes: 11 additions & 15 deletions packages/yasgui/src/Tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/yasgui/src/TabSettingsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
},
{
Expand Down
5 changes: 4 additions & 1 deletion packages/yasqe/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
1 change: 1 addition & 0 deletions packages/yasr/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
75 changes: 57 additions & 18 deletions packages/yasr/src/plugins/response/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
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;
Expand All @@ -27,7 +32,7 @@
private config: DeepReadonly<PluginConfig>;
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;
Expand Down Expand Up @@ -112,7 +117,7 @@
if (type === "json") return json();
if (type === "xml") return xml();
if (type === "ttl") return turtle();
if (type === "n-triples") return turtle();

Check failure on line 120 in packages/yasr/src/plugins/response/index.ts

View workflow job for this annotation

GitHub Actions / tests

This comparison appears to be unintentional because the types '"csv" | "tsv"' and '"n-triples"' have no overlap.

Check failure on line 120 in packages/yasr/src/plugins/response/index.ts

View workflow job for this annotation

GitHub Actions / build

This comparison appears to be unintentional because the types '"csv" | "tsv"' and '"n-triples"' have no overlap.
const contentType = this.yasr.results?.getContentType() || "";
if (contentType.indexOf("json") >= 0) return json();
if (contentType.indexOf("xml") >= 0 || contentType.indexOf("html") >= 0) return xml();
Expand All @@ -130,8 +135,8 @@
}

/**
* 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;
Expand All @@ -148,42 +153,75 @@

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();
Expand Down Expand Up @@ -211,12 +249,13 @@
}

/**
* 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();
}
Expand Down
60 changes: 55 additions & 5 deletions packages/yasr/src/plugins/response/uriUtils.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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<string>();
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");
}
Loading
Loading