@matdata/yasgui 5.14.0 → 5.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@matdata/yasgui",
3
3
  "description": "Yet Another SPARQL GUI",
4
- "version": "5.14.0",
4
+ "version": "5.15.0",
5
5
  "main": "build/yasgui.min.js",
6
6
  "types": "build/ts/src/index.d.ts",
7
7
  "license": "MIT",
package/src/Tab.ts CHANGED
@@ -17,6 +17,7 @@ import { saveManagedQuery } from "./queryManagement/saveManagedQuery";
17
17
  import { getWorkspaceBackend } from "./queryManagement/backends/getWorkspaceBackend";
18
18
  import { asWorkspaceBackendError } from "./queryManagement/backends/errors";
19
19
  import { normalizeQueryFilename } from "./queryManagement/normalizeQueryFilename";
20
+ import { resolveEndpointUrl } from "./urlUtils";
20
21
 
21
22
  export interface PersistedJsonYasr extends YasrPersistentConfig {
22
23
  responseSummary: Parser.ResponseSummary;
@@ -304,7 +305,7 @@ export class Tab extends EventEmitter {
304
305
  name: result.name,
305
306
  filename: result.filename,
306
307
  queryText: this.getQueryTextForSave(),
307
- associatedEndpoint: workspace.type === "sparql" ? this.getEndpoint() : undefined,
308
+ associatedEndpoint: workspace.type === "sparql" ? resolveEndpointUrl(this.getEndpoint()) : undefined,
308
309
  message: result.message,
309
310
  expectedVersionTag,
310
311
  });
@@ -1445,14 +1446,38 @@ export class Tab extends EventEmitter {
1445
1446
 
1446
1447
  // Check if token is a URI (not a variable)
1447
1448
  // URIs typically have token.type of 'string-2' or might be in angle brackets
1448
- const tokenString = token.string.trim();
1449
+ let tokenString = token.string.trim();
1449
1450
 
1450
1451
  // Skip if it's a variable (starts with ? or $)
1451
1452
  if (tokenString.startsWith("?") || tokenString.startsWith("$")) return;
1452
1453
 
1454
+ // Handle prefixed names that may be split into multiple tokens by the tokenizer
1455
+ // The tokenizer splits "prefix:localname" into two tokens:
1456
+ // - PNAME_LN_PREFIX (e.g., "bnd:") with type "string-2"
1457
+ // - PNAME_LN_LOCAL (e.g., "_subnetwork_bane_KVGB") with type "string"
1458
+ // We need to combine them to get the full prefixed name
1459
+ if (token.type === "string-2" && tokenString.endsWith(":")) {
1460
+ // This is a prefix token, get the next token for the local name
1461
+ const nextToken = this.yasqe.getTokenAt({ line: pos.line, ch: token.end + 1 });
1462
+ if (nextToken && nextToken.type === "string" && nextToken.start === token.end) {
1463
+ tokenString = `${tokenString}${nextToken.string.trim()}`;
1464
+ }
1465
+ } else if (token.type === "string" && token.start > 0) {
1466
+ // This might be a local name token, check if previous token is a prefix
1467
+ const prevToken = this.yasqe.getTokenAt({ line: pos.line, ch: token.start - 1 });
1468
+ if (
1469
+ prevToken &&
1470
+ prevToken.type === "string-2" &&
1471
+ prevToken.string.trim().endsWith(":") &&
1472
+ prevToken.end === token.start
1473
+ ) {
1474
+ tokenString = `${prevToken.string.trim()}${tokenString}`;
1475
+ }
1476
+ }
1477
+
1453
1478
  // Check if it's a URI - either in angle brackets or a prefixed name
1454
1479
  const isFullUri = tokenString.startsWith("<") && tokenString.endsWith(">");
1455
- const isPrefixedName = /^[\w-]+:[\w-]+/.test(tokenString);
1480
+ const isPrefixedName = /^[\w-]+:/.test(tokenString);
1456
1481
 
1457
1482
  if (!isFullUri && !isPrefixedName) return;
1458
1483
 
@@ -1467,7 +1492,8 @@ export class Tab extends EventEmitter {
1467
1492
  } else if (isPrefixedName) {
1468
1493
  // Expand prefixed name to full URI
1469
1494
  const prefixes = this.yasqe.getPrefixesFromQuery();
1470
- const [prefix, localName] = tokenString.split(":");
1495
+ const [prefix, ...localParts] = tokenString.split(":");
1496
+ const localName = localParts.join(":");
1471
1497
  const prefixUri = prefixes[prefix];
1472
1498
  if (prefixUri) {
1473
1499
  uri = prefixUri + localName;
@@ -39,6 +39,7 @@ export default class TabSettingsModal {
39
39
  private prefixButton!: HTMLButtonElement;
40
40
  private prefixTextarea!: HTMLTextAreaElement;
41
41
  private autoCaptureCheckbox!: HTMLInputElement;
42
+ private mouseDownOnOverlay = false;
42
43
  private handleKeyDown = (e: KeyboardEvent) => {
43
44
  if (e.key !== "Escape") return;
44
45
  if (!this.modalOverlay.classList.contains("open")) return;
@@ -91,8 +92,21 @@ export default class TabSettingsModal {
91
92
  // Modal overlay
92
93
  this.modalOverlay = document.createElement("div");
93
94
  addClass(this.modalOverlay, "tabSettingsModalOverlay");
94
- // Removed: this.modalOverlay.onclick = () => this.close();
95
- // Users must explicitly click 'Save' or 'Cancel' to close the modal
95
+
96
+ // Track mousedown on overlay to distinguish from text selection that moves outside
97
+ this.modalOverlay.addEventListener("mousedown", (e) => {
98
+ if (e.target === this.modalOverlay) {
99
+ this.mouseDownOnOverlay = true;
100
+ }
101
+ });
102
+
103
+ // Only close if mousedown also happened on overlay (not during text selection)
104
+ this.modalOverlay.addEventListener("mouseup", (e) => {
105
+ if (e.target === this.modalOverlay && this.mouseDownOnOverlay) {
106
+ this.close();
107
+ }
108
+ this.mouseDownOnOverlay = false;
109
+ });
96
110
 
97
111
  // Modal content
98
112
  this.modalContent = document.createElement("div");
@@ -912,10 +926,27 @@ export default class TabSettingsModal {
912
926
  const config = this.tab.yasgui.persistentConfig.getEndpointConfig(endpoint);
913
927
  const existingAuth = config?.authentication;
914
928
 
929
+ // Track mousedown for proper modal close behavior
930
+ let mouseDownOnOverlay = false;
931
+
915
932
  // Create modal overlay
916
933
  const authModalOverlay = document.createElement("div");
917
934
  addClass(authModalOverlay, "authModalOverlay");
918
- authModalOverlay.onclick = () => authModalOverlay.remove();
935
+
936
+ // Track mousedown on overlay to distinguish from text selection that moves outside
937
+ authModalOverlay.addEventListener("mousedown", (e) => {
938
+ if (e.target === authModalOverlay) {
939
+ mouseDownOnOverlay = true;
940
+ }
941
+ });
942
+
943
+ // Only close if mousedown also happened on overlay (not during text selection)
944
+ authModalOverlay.addEventListener("mouseup", (e) => {
945
+ if (e.target === authModalOverlay && mouseDownOnOverlay) {
946
+ authModalOverlay.remove();
947
+ }
948
+ mouseDownOnOverlay = false;
949
+ });
919
950
 
920
951
  // Create modal content
921
952
  const authModal = document.createElement("div");
@@ -1516,6 +1547,7 @@ export default class TabSettingsModal {
1516
1547
  }
1517
1548
 
1518
1549
  public close() {
1550
+ this.mouseDownOnOverlay = false;
1519
1551
  removeClass(this.modalOverlay, "open");
1520
1552
  document.removeEventListener("keydown", this.handleKeyDown);
1521
1553
  }
@@ -45,6 +45,7 @@ export default class SaveManagedQueryModal {
45
45
  private filenameTouched = false;
46
46
  private folderPickerOpen = false;
47
47
  private folderBrowsePath = "";
48
+ private mouseDownOnOverlay = false;
48
49
 
49
50
  private resolve?: (value: SaveManagedQueryModalResult) => void;
50
51
  private reject?: (reason?: unknown) => void;
@@ -247,7 +248,22 @@ export default class SaveManagedQueryModal {
247
248
 
248
249
  this.overlayEl.appendChild(this.modalEl);
249
250
 
250
- this.overlayEl.addEventListener("click", () => this.cancel());
251
+ // Track mousedown on overlay to distinguish from text selection that moves outside
252
+ this.overlayEl.addEventListener("mousedown", (e) => {
253
+ // Only mark as overlay mousedown if the target is the overlay itself (not modal content)
254
+ if (e.target === this.overlayEl) {
255
+ this.mouseDownOnOverlay = true;
256
+ }
257
+ });
258
+
259
+ // Only close if mousedown also happened on overlay (not during text selection)
260
+ this.overlayEl.addEventListener("mouseup", (e) => {
261
+ if (e.target === this.overlayEl && this.mouseDownOnOverlay) {
262
+ this.cancel();
263
+ }
264
+ this.mouseDownOnOverlay = false;
265
+ });
266
+
251
267
  document.addEventListener("keydown", (e) => {
252
268
  if (!this.isOpen()) return;
253
269
  if (e.key === "Escape") {
@@ -324,6 +340,7 @@ export default class SaveManagedQueryModal {
324
340
  }
325
341
 
326
342
  private cancel() {
343
+ this.mouseDownOnOverlay = false;
327
344
  this.close();
328
345
  this.overlayEl.remove();
329
346
  this.reject?.(new Error("cancelled"));
@@ -399,6 +416,7 @@ export default class SaveManagedQueryModal {
399
416
  }
400
417
  }
401
418
 
419
+ this.mouseDownOnOverlay = false;
402
420
  this.close();
403
421
  this.overlayEl.remove();
404
422
 
@@ -454,6 +472,7 @@ export default class SaveManagedQueryModal {
454
472
  this.folderBrowsePath = this.folderPathEl.value.trim();
455
473
  this.folderPickerErrorEl.textContent = "";
456
474
  this.folderPickerListEl.innerHTML = "";
475
+ this.mouseDownOnOverlay = false;
457
476
 
458
477
  document.body.appendChild(this.overlayEl);
459
478
  this.open();
@@ -27,6 +27,67 @@ export class WorkspaceSettingsForm {
27
27
  this.options = options;
28
28
  }
29
29
 
30
+ private async fetchExistingWorkspaces(endpoint: string): Promise<string[]> {
31
+ try {
32
+ const query = `
33
+ PREFIX yasgui: <https://matdata.eu/ns/yasgui#>
34
+
35
+ SELECT DISTINCT ?workspace WHERE {
36
+ ?workspace a yasgui:Workspace .
37
+ }
38
+ ORDER BY ?workspace`;
39
+
40
+ // Get authentication for this endpoint
41
+ const endpointConfig = this.options.persistentConfig.getEndpointConfig(endpoint);
42
+ const headers: Record<string, string> = {
43
+ "Content-Type": "application/x-www-form-urlencoded",
44
+ Accept: "application/sparql-results+json",
45
+ };
46
+
47
+ // Add authentication headers if configured
48
+ if (endpointConfig?.authentication) {
49
+ const auth = endpointConfig.authentication;
50
+ if (auth.type === "basic") {
51
+ const credentials = btoa(`${auth.username}:${auth.password}`);
52
+ headers["Authorization"] = `Basic ${credentials}`;
53
+ } else if (auth.type === "bearer") {
54
+ headers["Authorization"] = `Bearer ${auth.token}`;
55
+ } else if (auth.type === "apiKey") {
56
+ headers[auth.headerName] = auth.apiKey;
57
+ } else if (auth.type === "oauth2" && auth.accessToken) {
58
+ headers["Authorization"] = `Bearer ${auth.accessToken}`;
59
+ }
60
+ }
61
+
62
+ const response = await fetch(endpoint, {
63
+ method: "POST",
64
+ headers,
65
+ body: new URLSearchParams({ query }),
66
+ });
67
+
68
+ if (!response.ok) {
69
+ throw new Error(`HTTP ${response.status}`);
70
+ }
71
+
72
+ const data = await response.json();
73
+ const workspaces: string[] = [];
74
+
75
+ if (data.results?.bindings) {
76
+ for (const binding of data.results.bindings) {
77
+ const workspace = binding.workspace?.value;
78
+ if (workspace) {
79
+ workspaces.push(workspace);
80
+ }
81
+ }
82
+ }
83
+
84
+ return workspaces;
85
+ } catch (error) {
86
+ console.error("Failed to fetch existing workspaces:", error);
87
+ return [];
88
+ }
89
+ }
90
+
30
91
  public render() {
31
92
  this.container.innerHTML = "";
32
93
 
@@ -234,6 +295,9 @@ export class WorkspaceSettingsForm {
234
295
  }
235
296
 
236
297
  private openWorkspaceConfigModal(input: { mode: "add" } | { mode: "edit"; workspace: WorkspaceConfig }) {
298
+ // Track mousedown for proper modal close behavior
299
+ let mouseDownOnOverlay = false;
300
+
237
301
  const overlay = document.createElement("div");
238
302
  addClass(overlay, "tabSettingsModalOverlay", "workspaceConfigModalOverlay", "open");
239
303
 
@@ -242,6 +306,7 @@ export class WorkspaceSettingsForm {
242
306
  modal.onclick = (e) => e.stopPropagation();
243
307
 
244
308
  const close = () => {
309
+ mouseDownOnOverlay = false;
245
310
  overlay.remove();
246
311
  document.removeEventListener("keydown", onKeyDown);
247
312
  };
@@ -253,7 +318,20 @@ export class WorkspaceSettingsForm {
253
318
  };
254
319
  document.addEventListener("keydown", onKeyDown);
255
320
 
256
- overlay.onclick = () => close();
321
+ // Track mousedown on overlay to distinguish from text selection that moves outside
322
+ overlay.addEventListener("mousedown", (e) => {
323
+ if (e.target === overlay) {
324
+ mouseDownOnOverlay = true;
325
+ }
326
+ });
327
+
328
+ // Only close if mousedown also happened on overlay (not during text selection)
329
+ overlay.addEventListener("mouseup", (e) => {
330
+ if (e.target === overlay && mouseDownOnOverlay) {
331
+ close();
332
+ }
333
+ mouseDownOnOverlay = false;
334
+ });
257
335
 
258
336
  const header = document.createElement("div");
259
337
  addClass(header, "modalHeader");
@@ -440,11 +518,133 @@ export class WorkspaceSettingsForm {
440
518
  sparqlHelp.textContent =
441
519
  "Tip: you can reuse an existing Workspace IRI to point to an already-populated workspace, or choose a new IRI to start a fresh workspace.";
442
520
 
443
- const workspaceIriInput = document.createElement("input");
444
- workspaceIriInput.type = "url";
445
- workspaceIriInput.placeholder = "Workspace IRI";
446
- workspaceIriInput.value = existing && existing.type === "sparql" ? existing.workspaceIri : "";
447
- addClass(workspaceIriInput, "settingsInput");
521
+ // Workspace IRI selection (dropdown + custom input)
522
+ const workspaceIriSelect = document.createElement("select");
523
+ workspaceIriSelect.setAttribute("aria-label", "Workspace IRI");
524
+ addClass(workspaceIriSelect, "settingsSelect");
525
+
526
+ const loadingOption = document.createElement("option");
527
+ loadingOption.value = "";
528
+ loadingOption.textContent = "Select endpoint first";
529
+ workspaceIriSelect.appendChild(loadingOption);
530
+
531
+ const workspaceIriCustomInput = document.createElement("input");
532
+ workspaceIriCustomInput.type = "url";
533
+ workspaceIriCustomInput.placeholder =
534
+ "Enter new workspace IRI (e.g., https://example.org/workspace/my-workspace)";
535
+ workspaceIriCustomInput.style.display = "none";
536
+ workspaceIriCustomInput.style.marginTop = "5px";
537
+ addClass(workspaceIriCustomInput, "settingsInput");
538
+
539
+ if (existing && existing.type === "sparql") {
540
+ workspaceIriCustomInput.value = existing.workspaceIri;
541
+ }
542
+
543
+ // Function to populate workspace options
544
+ const populateWorkspaceOptions = async (endpoint: string) => {
545
+ // Clear existing options
546
+ workspaceIriSelect.innerHTML = "";
547
+
548
+ // Add loading option
549
+ const loading = document.createElement("option");
550
+ loading.value = "";
551
+ loading.textContent = "Loading workspaces...";
552
+ workspaceIriSelect.appendChild(loading);
553
+ workspaceIriSelect.disabled = true;
554
+
555
+ try {
556
+ const workspaces = await this.fetchExistingWorkspaces(endpoint);
557
+
558
+ workspaceIriSelect.innerHTML = "";
559
+ workspaceIriSelect.disabled = false;
560
+
561
+ if (workspaces.length > 0) {
562
+ const selectPlaceholder = document.createElement("option");
563
+ selectPlaceholder.value = "";
564
+ selectPlaceholder.textContent = "Select existing workspace";
565
+ workspaceIriSelect.appendChild(selectPlaceholder);
566
+
567
+ for (const workspace of workspaces) {
568
+ const option = document.createElement("option");
569
+ option.value = workspace;
570
+ option.textContent = workspace;
571
+ workspaceIriSelect.appendChild(option);
572
+ }
573
+ } else {
574
+ const noWorkspaces = document.createElement("option");
575
+ noWorkspaces.value = "";
576
+ noWorkspaces.textContent = "No existing workspaces found";
577
+ workspaceIriSelect.appendChild(noWorkspaces);
578
+ }
579
+
580
+ // Add custom option
581
+ const customOption = document.createElement("option");
582
+ customOption.value = "__custom__";
583
+ customOption.textContent = "➕ Enter new workspace IRI";
584
+ workspaceIriSelect.appendChild(customOption);
585
+
586
+ // Set current value if editing existing
587
+ if (existing && existing.type === "sparql") {
588
+ const existingIri = existing.workspaceIri;
589
+ const matchingOption = workspaces.find((w) => w === existingIri);
590
+ if (matchingOption) {
591
+ workspaceIriSelect.value = existingIri;
592
+ } else {
593
+ workspaceIriSelect.value = "__custom__";
594
+ workspaceIriCustomInput.style.display = "";
595
+ workspaceIriCustomInput.value = existingIri;
596
+ }
597
+ }
598
+ } catch (error) {
599
+ workspaceIriSelect.innerHTML = "";
600
+ workspaceIriSelect.disabled = false;
601
+
602
+ const errorOption = document.createElement("option");
603
+ errorOption.value = "";
604
+ errorOption.textContent = "Failed to load workspaces";
605
+ workspaceIriSelect.appendChild(errorOption);
606
+
607
+ const customOption = document.createElement("option");
608
+ customOption.value = "__custom__";
609
+ customOption.textContent = "➕ Enter new workspace IRI";
610
+ workspaceIriSelect.appendChild(customOption);
611
+ }
612
+ };
613
+
614
+ // Handle workspace selection change
615
+ workspaceIriSelect.onchange = () => {
616
+ if (workspaceIriSelect.value === "__custom__") {
617
+ workspaceIriCustomInput.style.display = "";
618
+ workspaceIriCustomInput.focus();
619
+ } else {
620
+ workspaceIriCustomInput.style.display = "none";
621
+ }
622
+ };
623
+
624
+ // Handle endpoint change
625
+ endpointSelect.onchange = () => {
626
+ const selectedEndpoint = endpointSelect.value.trim();
627
+ if (selectedEndpoint) {
628
+ void populateWorkspaceOptions(selectedEndpoint);
629
+ } else {
630
+ workspaceIriSelect.innerHTML = "";
631
+ const placeholder = document.createElement("option");
632
+ placeholder.value = "";
633
+ placeholder.textContent = "Select endpoint first";
634
+ workspaceIriSelect.appendChild(placeholder);
635
+ }
636
+ };
637
+
638
+ // Initial load if endpoint is already selected
639
+ const initialEndpoint = endpointSelect.value.trim();
640
+ if (initialEndpoint) {
641
+ void populateWorkspaceOptions(initialEndpoint);
642
+ }
643
+
644
+ // Wrapper for workspace IRI selection
645
+ const workspaceIriWrapper = document.createElement("div");
646
+ workspaceIriWrapper.appendChild(workspaceIriSelect);
647
+ workspaceIriWrapper.appendChild(workspaceIriCustomInput);
448
648
 
449
649
  const defaultGraphInput = document.createElement("input");
450
650
  defaultGraphInput.type = "url";
@@ -454,17 +654,24 @@ export class WorkspaceSettingsForm {
454
654
 
455
655
  dynamic.appendChild(this.wrapField("SPARQL endpoint", endpointSelect));
456
656
  dynamic.appendChild(sparqlHelp);
457
- dynamic.appendChild(this.wrapField("Workspace IRI", workspaceIriInput));
657
+ dynamic.appendChild(this.wrapField("Workspace IRI", workspaceIriWrapper));
458
658
  dynamic.appendChild(this.wrapField("Default graph", defaultGraphInput));
459
659
 
460
660
  (dynamic as any).__getConfig = (): WorkspaceConfig => {
661
+ let workspaceIri = "";
662
+ if (workspaceIriSelect.value === "__custom__") {
663
+ workspaceIri = workspaceIriCustomInput.value.trim();
664
+ } else {
665
+ workspaceIri = workspaceIriSelect.value.trim();
666
+ }
667
+
461
668
  return {
462
669
  id: existing?.id || newWorkspaceId(),
463
670
  type: "sparql",
464
671
  label: labelInput.value.trim(),
465
672
  description: descriptionInput.value.trim() || undefined,
466
673
  endpoint: endpointSelect.value.trim(),
467
- workspaceIri: workspaceIriInput.value.trim(),
674
+ workspaceIri,
468
675
  defaultGraph: defaultGraphInput.value.trim() || undefined,
469
676
  };
470
677
  };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Converts a relative or absolute URL to a fully qualified URL with protocol and host.
3
+ * Uses the current page's protocol and host for relative URLs.
4
+ *
5
+ * @param url - The URL to resolve (can be relative like "/sparql", or absolute like "http://example.com/sparql")
6
+ * @returns The fully qualified URL with protocol and host
7
+ *
8
+ * @example
9
+ * // On page https://example.com/yasgui/
10
+ * resolveEndpointUrl("/sparql") // returns "https://example.com/sparql"
11
+ * resolveEndpointUrl("sparql") // returns "https://example.com/yasgui/sparql"
12
+ * resolveEndpointUrl("http://other.com/sparql") // returns "http://other.com/sparql"
13
+ */
14
+ export function resolveEndpointUrl(url: string): string {
15
+ if (!url) return url;
16
+
17
+ // If URL already has a protocol (http: or https:), return as-is
18
+ if (url.startsWith("http://") || url.startsWith("https://")) {
19
+ return url;
20
+ }
21
+
22
+ // Build the base URL using current page's protocol and host
23
+ let fullUrl = `${window.location.protocol}//${window.location.host}`;
24
+
25
+ if (url.startsWith("/")) {
26
+ // Absolute path (starts with /)
27
+ fullUrl += url;
28
+ } else {
29
+ // Relative path - join with current page's directory
30
+ let currentDirectory = window.location.pathname;
31
+ // If pathname does not end with "/", treat it as a file and use its directory
32
+ if (!currentDirectory.endsWith("/")) {
33
+ const lastSlashIndex = currentDirectory.lastIndexOf("/");
34
+ currentDirectory = lastSlashIndex >= 0 ? currentDirectory.substring(0, lastSlashIndex + 1) : "/";
35
+ }
36
+ fullUrl += currentDirectory + url;
37
+ }
38
+
39
+ return fullUrl;
40
+ }
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // Version information for YASGUI
2
2
  // This file is auto-generated during build - do not edit manually
3
- export const VERSION = "5.14.0";
3
+ export const VERSION = "5.15.0";