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
87 changes: 87 additions & 0 deletions dev/quick-describe-test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quick Describe Test - YASGUI</title>
<style>
html,
body {
height: 100%;
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
}
#yasgui {
height: calc(100% - 160px);
}
.info-banner {
background-color: #e3f2fd;
border: 1px solid #1976d2;
border-radius: 4px;
padding: 16px;
margin: 16px;
font-size: 14px;
}
.info-banner h3 {
margin-top: 0;
color: #1976d2;
}
.info-banner code {
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
.info-banner ul {
margin: 8px 0;
padding-left: 20px;
}
</style>
</head>
<body>
<div class="info-banner">
<h3>🧪 Quick Describe Feature Test</h3>
<p><strong>Instructions:</strong> Hold <code>Ctrl</code> (or <code>Cmd</code> on Mac) and click on any URI in the query below to trigger a quick describe query.</p>
<p><strong>Test URIs:</strong></p>
<ul>
<li><code>bnd:_subnetwork_bane_KVGB</code> - URI with underscore prefix</li>
<li><code>bno:BaneSubnetwork</code> - URI with camel case</li>
</ul>
<p><strong>Expected behavior:</strong> Clicking on these URIs should execute a CONSTRUCT query to describe the resource (even though the endpoint may not exist).</p>
</div>
<div id="yasgui"></div>

<script type="module">
import Yasgui from '../packages/yasgui/src/index.ts';

const testQuery = `PREFIX bnd: <https://data.banenor.no/data/>
PREFIX rtm: <http://ontology.railml.org/railtopomodel#>
PREFIX time: <http://www.w3.org/2006/time#>
PREFIX dcterms: <http://purl.org/dc/terms/>
PREFIX gsp: <http://www.opengis.net/ont/geosparql#>
PREFIX bno: <https://data.banenor.no/ontology/>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

SELECT *
WHERE {
bnd:_subnetwork_bane_KVGB a bno:BaneSubnetwork .
}
LIMIT 1000`;

const yasgui = new Yasgui(document.getElementById("yasgui"), {
requestConfig: {
endpoint: "https://data.banenor.no/sparql",
},
copyEndpointOnNewTab: true,
});

// Set the test query
yasgui.getTab().yasqe.setValue(testQuery);

// Add a console log to track when quick describe is triggered
console.log("Test page loaded. Hold Ctrl and click on URIs to test quick describe feature.");
</script>
</body>
</html>
34 changes: 30 additions & 4 deletions packages/yasgui/src/Tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { saveManagedQuery } from "./queryManagement/saveManagedQuery";
import { getWorkspaceBackend } from "./queryManagement/backends/getWorkspaceBackend";
import { asWorkspaceBackendError } from "./queryManagement/backends/errors";
import { normalizeQueryFilename } from "./queryManagement/normalizeQueryFilename";
import { resolveEndpointUrl } from "./urlUtils";

export interface PersistedJsonYasr extends YasrPersistentConfig {
responseSummary: Parser.ResponseSummary;
Expand Down Expand Up @@ -304,7 +305,7 @@ export class Tab extends EventEmitter {
name: result.name,
filename: result.filename,
queryText: this.getQueryTextForSave(),
associatedEndpoint: workspace.type === "sparql" ? this.getEndpoint() : undefined,
associatedEndpoint: workspace.type === "sparql" ? resolveEndpointUrl(this.getEndpoint()) : undefined,
message: result.message,
expectedVersionTag,
});
Expand Down Expand Up @@ -1445,14 +1446,38 @@ export class Tab extends EventEmitter {

// Check if token is a URI (not a variable)
// URIs typically have token.type of 'string-2' or might be in angle brackets
const tokenString = token.string.trim();
let tokenString = token.string.trim();

// Skip if it's a variable (starts with ? or $)
if (tokenString.startsWith("?") || tokenString.startsWith("$")) return;

// Handle prefixed names that may be split into multiple tokens by the tokenizer
// The tokenizer splits "prefix:localname" into two tokens:
// - PNAME_LN_PREFIX (e.g., "bnd:") with type "string-2"
// - PNAME_LN_LOCAL (e.g., "_subnetwork_bane_KVGB") with type "string"
// We need to combine them to get the full prefixed name
if (token.type === "string-2" && tokenString.endsWith(":")) {
// This is a prefix token, get the next token for the local name
const nextToken = this.yasqe.getTokenAt({ line: pos.line, ch: token.end + 1 });
if (nextToken && nextToken.type === "string" && nextToken.start === token.end) {
tokenString = `${tokenString}${nextToken.string.trim()}`;
}
} else if (token.type === "string" && token.start > 0) {
// This might be a local name token, check if previous token is a prefix
const prevToken = this.yasqe.getTokenAt({ line: pos.line, ch: token.start - 1 });
if (
prevToken &&
prevToken.type === "string-2" &&
prevToken.string.trim().endsWith(":") &&
prevToken.end === token.start
) {
tokenString = `${prevToken.string.trim()}${tokenString}`;
}
}

// Check if it's a URI - either in angle brackets or a prefixed name
const isFullUri = tokenString.startsWith("<") && tokenString.endsWith(">");
const isPrefixedName = /^[\w-]+:[\w-]+/.test(tokenString);
const isPrefixedName = /^[\w-]+:/.test(tokenString);

if (!isFullUri && !isPrefixedName) return;

Expand All @@ -1467,7 +1492,8 @@ export class Tab extends EventEmitter {
} else if (isPrefixedName) {
// Expand prefixed name to full URI
const prefixes = this.yasqe.getPrefixesFromQuery();
const [prefix, localName] = tokenString.split(":");
const [prefix, ...localParts] = tokenString.split(":");
const localName = localParts.join(":");
const prefixUri = prefixes[prefix];
if (prefixUri) {
uri = prefixUri + localName;
Expand Down
40 changes: 40 additions & 0 deletions packages/yasgui/src/urlUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Converts a relative or absolute URL to a fully qualified URL with protocol and host.
* Uses the current page's protocol and host for relative URLs.
*
* @param url - The URL to resolve (can be relative like "/sparql", or absolute like "http://example.com/sparql")
* @returns The fully qualified URL with protocol and host
*
* @example
* // On page https://example.com/yasgui/
* resolveEndpointUrl("/sparql") // returns "https://example.com/sparql"
* resolveEndpointUrl("sparql") // returns "https://example.com/yasgui/sparql"
* resolveEndpointUrl("http://other.com/sparql") // returns "http://other.com/sparql"
*/
export function resolveEndpointUrl(url: string): string {
if (!url) return url;

// If URL already has a protocol (http: or https:), return as-is
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}

// Build the base URL using current page's protocol and host
let fullUrl = `${window.location.protocol}//${window.location.host}`;

if (url.startsWith("/")) {
// Absolute path (starts with /)
fullUrl += url;
} else {
// Relative path - join with current page's directory
let currentDirectory = window.location.pathname;
// If pathname does not end with "/", treat it as a file and use its directory
if (!currentDirectory.endsWith("/")) {
const lastSlashIndex = currentDirectory.lastIndexOf("/");
currentDirectory = lastSlashIndex >= 0 ? currentDirectory.substring(0, lastSlashIndex + 1) : "/";
}
fullUrl += currentDirectory + url;
}

return fullUrl;
}
86 changes: 86 additions & 0 deletions test/unit/url-utils-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import * as chai from "chai";
import { describe, it, beforeEach, afterEach } from "mocha";

import { resolveEndpointUrl } from "../../packages/yasgui/src/urlUtils.js";

const expect = chai.expect;

describe("URL utilities - resolveEndpointUrl", () => {
let originalWindow: any;

beforeEach(() => {
// Save the original window object if it exists
originalWindow = (global as any).window;
});

afterEach(() => {
// Restore the original window object
if (originalWindow) {
(global as any).window = originalWindow;
} else {
delete (global as any).window;
}
});

function mockWindow(url: string) {
const parsedUrl = new URL(url);
(global as any).window = {
location: {
protocol: parsedUrl.protocol,
host: parsedUrl.host,
hostname: parsedUrl.hostname,
port: parsedUrl.port,
pathname: parsedUrl.pathname,
href: url,
},
};
}

it("returns absolute URLs unchanged (http)", () => {
mockWindow("https://example.com/yasgui/");
const result = resolveEndpointUrl("http://example.com/sparql");
expect(result).to.equal("http://example.com/sparql");
});

it("returns absolute URLs unchanged (https)", () => {
mockWindow("https://example.com/yasgui/");
const result = resolveEndpointUrl("https://example.com/sparql");
expect(result).to.equal("https://example.com/sparql");
});

it("converts absolute path to full URL with current protocol and host", () => {
mockWindow("https://example.com/yasgui/index.html");
const result = resolveEndpointUrl("/sparql");
expect(result).to.equal("https://example.com/sparql");
});

it("converts relative path to full URL with current directory", () => {
mockWindow("https://example.com/yasgui/");
const result = resolveEndpointUrl("sparql");
expect(result).to.equal("https://example.com/yasgui/sparql");
});

it("uses https protocol when page is https", () => {
mockWindow("https://secure.example.com/app/");
const result = resolveEndpointUrl("/sparql");
expect(result).to.equal("https://secure.example.com/sparql");
});

it("uses http protocol when page is http", () => {
mockWindow("http://example.com/app/");
const result = resolveEndpointUrl("/sparql");
expect(result).to.equal("http://example.com/sparql");
});

it("handles empty string", () => {
mockWindow("https://example.com/app/");
const result = resolveEndpointUrl("");
expect(result).to.equal("");
});

it("includes port number if present", () => {
mockWindow("https://example.com:8080/app/");
const result = resolveEndpointUrl("/sparql");
expect(result).to.equal("https://example.com:8080/sparql");
});
});