@matdata/yasgui 5.13.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.13.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;
@@ -290,6 +290,63 @@
290
290
  }
291
291
  }
292
292
 
293
+ .keyValueContainer {
294
+ display: flex;
295
+ flex-direction: column;
296
+ gap: 8px;
297
+ }
298
+
299
+ .keyValueRow {
300
+ display: grid;
301
+ grid-template-columns: 1fr 1fr auto;
302
+ gap: 8px;
303
+ align-items: center;
304
+
305
+ .keyInput,
306
+ .valueInput {
307
+ padding: 8px;
308
+ border: 1px solid var(--yasgui-input-border, #ccc);
309
+ border-radius: 4px;
310
+ font-size: 14px;
311
+ background-color: var(--yasgui-bg-secondary, white);
312
+ color: var(--yasgui-text-primary, #000);
313
+
314
+ &:focus {
315
+ outline: none;
316
+ border-color: var(--yasgui-input-focus, #337ab7);
317
+ box-shadow: 0 0 0 2px rgba(51, 122, 183, 0.1);
318
+ }
319
+
320
+ &::placeholder {
321
+ color: var(--yasgui-text-secondary, #999);
322
+ }
323
+ }
324
+
325
+ .removeButton {
326
+ padding: 6px 10px;
327
+ background-color: var(--yasgui-danger-color, #d9534f);
328
+ color: white;
329
+ border: none;
330
+ border-radius: 4px;
331
+ cursor: pointer;
332
+ font-size: 14px;
333
+ transition: background-color 0.2s;
334
+
335
+ &:hover {
336
+ background-color: var(--yasgui-danger-hover, #c9302c);
337
+ }
338
+
339
+ &:focus {
340
+ outline: none;
341
+ box-shadow: 0 0 0 2px rgba(217, 83, 79, 0.3);
342
+ }
343
+
344
+ i {
345
+ pointer-events: none;
346
+ }
347
+ }
348
+ }
349
+
293
350
  .prefixTextarea {
294
351
  width: 100%;
295
352
  padding: 10px;
@@ -24,6 +24,9 @@ const AcceptHeaderGraphMap: { key: string; value: string }[] = [
24
24
  { key: "TSV", value: "text/tab-separated-values,*/*;q=0.9" },
25
25
  ];
26
26
 
27
+ // Type for key-value pairs (headers, args, etc.)
28
+ type KeyValuePair = { name: string; value: string };
29
+
27
30
  // Default API Key header name
28
31
  const DEFAULT_API_KEY_HEADER = "X-API-Key";
29
32
 
@@ -36,6 +39,7 @@ export default class TabSettingsModal {
36
39
  private prefixButton!: HTMLButtonElement;
37
40
  private prefixTextarea!: HTMLTextAreaElement;
38
41
  private autoCaptureCheckbox!: HTMLInputElement;
42
+ private mouseDownOnOverlay = false;
39
43
  private handleKeyDown = (e: KeyboardEvent) => {
40
44
  if (e.key !== "Escape") return;
41
45
  if (!this.modalOverlay.classList.contains("open")) return;
@@ -88,8 +92,21 @@ export default class TabSettingsModal {
88
92
  // Modal overlay
89
93
  this.modalOverlay = document.createElement("div");
90
94
  addClass(this.modalOverlay, "tabSettingsModalOverlay");
91
- // Removed: this.modalOverlay.onclick = () => this.close();
92
- // 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
+ });
93
110
 
94
111
  // Modal content
95
112
  this.modalContent = document.createElement("div");
@@ -909,10 +926,27 @@ export default class TabSettingsModal {
909
926
  const config = this.tab.yasgui.persistentConfig.getEndpointConfig(endpoint);
910
927
  const existingAuth = config?.authentication;
911
928
 
929
+ // Track mousedown for proper modal close behavior
930
+ let mouseDownOnOverlay = false;
931
+
912
932
  // Create modal overlay
913
933
  const authModalOverlay = document.createElement("div");
914
934
  addClass(authModalOverlay, "authModalOverlay");
915
- 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
+ });
916
950
 
917
951
  // Create modal content
918
952
  const authModal = document.createElement("div");
@@ -1380,6 +1414,81 @@ export default class TabSettingsModal {
1380
1414
  acceptGraphSelect.setAttribute("data-config", "acceptHeaderGraph");
1381
1415
  acceptGraphSection.appendChild(acceptGraphSelect);
1382
1416
  container.appendChild(acceptGraphSection);
1417
+
1418
+ // Custom Headers section
1419
+ const headersSection = this.createSection("Custom HTTP Headers");
1420
+ const headersContainer = document.createElement("div");
1421
+ addClass(headersContainer, "keyValueContainer");
1422
+ headersContainer.setAttribute("data-config", "headers");
1423
+
1424
+ // Get existing headers
1425
+ const existingHeaders = typeof reqConfig.headers === "function" ? {} : reqConfig.headers || {};
1426
+ const headerPairs: KeyValuePair[] = Object.entries(existingHeaders).map(([name, value]) => ({
1427
+ name,
1428
+ value,
1429
+ }));
1430
+
1431
+ // Draw existing headers
1432
+ headerPairs.forEach((pair, index) => {
1433
+ this.createKeyValueRow(headersContainer, pair, index);
1434
+ });
1435
+
1436
+ // Add empty row for adding new headers
1437
+ this.createKeyValueRow(headersContainer, { name: "", value: "" }, headerPairs.length);
1438
+
1439
+ headersSection.appendChild(headersContainer);
1440
+ container.appendChild(headersSection);
1441
+ }
1442
+
1443
+ private createKeyValueRow(container: HTMLElement, pair: KeyValuePair, index: number) {
1444
+ const row = document.createElement("div");
1445
+ addClass(row, "keyValueRow");
1446
+
1447
+ const nameInput = document.createElement("input");
1448
+ nameInput.type = "text";
1449
+ nameInput.placeholder = "Header Name";
1450
+ nameInput.value = pair.name;
1451
+ addClass(nameInput, "keyInput");
1452
+
1453
+ const valueInput = document.createElement("input");
1454
+ valueInput.type = "text";
1455
+ valueInput.placeholder = "Header Value";
1456
+ valueInput.value = pair.value;
1457
+ addClass(valueInput, "valueInput");
1458
+
1459
+ const removeButton = document.createElement("button");
1460
+ removeButton.type = "button";
1461
+ removeButton.innerHTML = '<i class="fas fa-times"></i>';
1462
+ removeButton.title = "Remove header";
1463
+ addClass(removeButton, "removeButton");
1464
+
1465
+ // Auto-add new empty row when typing in the last row
1466
+ const onInput = () => {
1467
+ const allRows = container.querySelectorAll(".keyValueRow");
1468
+ const isLastRow = row === allRows[allRows.length - 1];
1469
+
1470
+ if (isLastRow && (nameInput.value.trim() || valueInput.value.trim())) {
1471
+ // Add a new empty row
1472
+ this.createKeyValueRow(container, { name: "", value: "" }, allRows.length);
1473
+ }
1474
+ };
1475
+
1476
+ nameInput.addEventListener("input", onInput);
1477
+ valueInput.addEventListener("input", onInput);
1478
+
1479
+ removeButton.onclick = () => {
1480
+ row.remove();
1481
+ // Ensure there's always at least one empty row
1482
+ const remainingRows = container.querySelectorAll(".keyValueRow");
1483
+ if (remainingRows.length === 0) {
1484
+ this.createKeyValueRow(container, { name: "", value: "" }, 0);
1485
+ }
1486
+ };
1487
+
1488
+ row.appendChild(nameInput);
1489
+ row.appendChild(valueInput);
1490
+ row.appendChild(removeButton);
1491
+ container.appendChild(row);
1383
1492
  }
1384
1493
 
1385
1494
  private createSection(title: string): HTMLElement {
@@ -1438,6 +1547,7 @@ export default class TabSettingsModal {
1438
1547
  }
1439
1548
 
1440
1549
  public close() {
1550
+ this.mouseDownOnOverlay = false;
1441
1551
  removeClass(this.modalOverlay, "open");
1442
1552
  document.removeEventListener("keydown", this.handleKeyDown);
1443
1553
  }
@@ -1546,6 +1656,22 @@ export default class TabSettingsModal {
1546
1656
  updates[config] = (select as HTMLSelectElement).value;
1547
1657
  }
1548
1658
  });
1659
+
1660
+ // Save custom headers
1661
+ const headersContainer = requestContent.querySelector(".keyValueContainer[data-config='headers']");
1662
+ if (headersContainer) {
1663
+ const headers: { [key: string]: string } = {};
1664
+ const rows = headersContainer.querySelectorAll(".keyValueRow");
1665
+ rows.forEach((row) => {
1666
+ const nameInput = row.querySelector(".keyInput") as HTMLInputElement;
1667
+ const valueInput = row.querySelector(".valueInput") as HTMLInputElement;
1668
+ if (nameInput && valueInput && nameInput.value.trim()) {
1669
+ headers[nameInput.value.trim()] = valueInput.value;
1670
+ }
1671
+ });
1672
+ updates.headers = headers;
1673
+ }
1674
+
1549
1675
  this.tab.setRequestConfig(updates);
1550
1676
  }
1551
1677
 
@@ -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
  };