@matdata/yasqe 5.2.0 → 5.4.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.
@@ -0,0 +1,9 @@
1
+ export interface TokenizerTable {
2
+ [key: string]: any;
3
+ }
4
+
5
+ export const table: TokenizerTable;
6
+ export const keywords: any;
7
+ export const punct: any;
8
+ export const acceptEmpty: any;
9
+ export const startSymbol: string;
@@ -36,6 +36,10 @@ export interface State {
36
36
  lastPredicateOffset: number;
37
37
  currentPnameNs: string | undefined;
38
38
  possibleFullIri: boolean;
39
+ inConstructTemplate: boolean;
40
+ seenWhereClause: boolean;
41
+ constructVariables: { [varName: string]: string };
42
+ whereVariables: { [varName: string]: string };
39
43
  }
40
44
  export interface Token {
41
45
  quotePos: "end" | "start" | "content" | undefined;
@@ -467,6 +471,13 @@ export default function (config: CodeMirror.EditorConfiguration): CodeMirror.Mod
467
471
  case "storeProperty":
468
472
  state.storeProperty = true;
469
473
  break;
474
+ case "constructTemplate":
475
+ state.inConstructTemplate = true;
476
+ break;
477
+ case "whereClause":
478
+ state.inConstructTemplate = false;
479
+ state.seenWhereClause = true;
480
+ break;
470
481
  }
471
482
  }
472
483
 
@@ -517,7 +528,18 @@ export default function (config: CodeMirror.EditorConfiguration): CodeMirror.Mod
517
528
  // Incremental LL1 parse
518
529
  while (state.stack.length > 0 && tokenCat && state.OK && !finished) {
519
530
  topSymbol = state.stack.pop();
520
- if (topSymbol === "var" && tokenOb.string) state.variables[tokenOb.string] = tokenOb.string;
531
+ if (topSymbol === "var" && tokenOb.string) {
532
+ state.variables[tokenOb.string] = tokenOb.string;
533
+ // Track variables separately for CONSTRUCT template validation
534
+ if (state.queryType === "CONSTRUCT") {
535
+ if (state.inConstructTemplate) {
536
+ state.constructVariables[tokenOb.string] = tokenOb.string;
537
+ } else if (state.seenWhereClause) {
538
+ // Only track as WHERE variable if we've actually entered the WHERE clause
539
+ state.whereVariables[tokenOb.string] = tokenOb.string;
540
+ }
541
+ }
542
+ }
521
543
  if (!ll1_table[topSymbol]) {
522
544
  // Top symbol is a terminal
523
545
  if (topSymbol == tokenCat) {
@@ -721,6 +743,10 @@ export default function (config: CodeMirror.EditorConfiguration): CodeMirror.Mod
721
743
  errorMsg: undefined,
722
744
  inPrefixDecl: false,
723
745
  possibleFullIri: false,
746
+ inConstructTemplate: false,
747
+ seenWhereClause: false,
748
+ constructVariables: {},
749
+ whereVariables: {},
724
750
  };
725
751
  },
726
752
  indent: indent,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@matdata/yasqe",
3
3
  "description": "Yet Another SPARQL Query Editor",
4
- "version": "5.2.0",
4
+ "version": "5.4.0",
5
5
  "main": "build/yasqe.min.js",
6
6
  "types": "build/ts/src/index.d.ts",
7
7
  "license": "MIT",
@@ -27,7 +27,8 @@
27
27
  "@matdata/yasgui-utils": "^4.6.1",
28
28
  "codemirror": "^5.51.0",
29
29
  "lodash-es": "^4.17.15",
30
- "query-string": "^6.10.1"
30
+ "query-string": "^6.10.1",
31
+ "sparql-formatter": "^1.0.2"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@types/codemirror": "0.0.100",
@@ -17,8 +17,7 @@
17
17
 
18
18
  background: white;
19
19
  font-size: 90%;
20
- font-family: monospace;
21
-
20
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
22
21
  max-height: 20em;
23
22
  overflow-y: auto;
24
23
  }
package/src/defaults.ts CHANGED
@@ -27,7 +27,7 @@ SELECT * WHERE {
27
27
  rangeFinder: new (<any>CodeMirror).fold.combine((<any>CodeMirror).fold.brace, (<any>CodeMirror).fold.prefix),
28
28
  },
29
29
  collapsePrefixesOnLoad: false,
30
- gutters: ["gutterErrorBar", "CodeMirror-linenumbers", "CodeMirror-foldgutter"],
30
+ gutters: ["gutterErrorBar", "gutterConstructWarning", "CodeMirror-linenumbers", "CodeMirror-foldgutter"],
31
31
  matchBrackets: true,
32
32
  fixedGutter: true,
33
33
  syntaxErrorCheck: true,
@@ -68,7 +68,7 @@ SELECT * WHERE {
68
68
  },
69
69
  "Shift-Ctrl-F": function (_yasqe: any) {
70
70
  const yasqe: Yasqe = _yasqe;
71
- yasqe.autoformat();
71
+ yasqe.format();
72
72
  },
73
73
  "Ctrl-S": function (_yasqe: any) {
74
74
  const yasqe: Yasqe = _yasqe;
@@ -130,6 +130,36 @@ SELECT * WHERE {
130
130
  editorHeight: "300px",
131
131
  queryingDisabled: undefined,
132
132
  prefixCcApi: prefixCcApi,
133
+ showFormatButton: true,
134
+ checkConstructVariables: true,
135
+ snippets: [
136
+ {
137
+ label: "SELECT",
138
+ code: "SELECT * WHERE {\n ?s ?p ?o .\n} LIMIT 10",
139
+ group: "Query Types",
140
+ },
141
+ {
142
+ label: "CONSTRUCT",
143
+ code: "CONSTRUCT {\n ?s ?p ?o .\n} WHERE {\n ?s ?p ?o .\n} LIMIT 10",
144
+ group: "Query Types",
145
+ },
146
+ {
147
+ label: "ASK",
148
+ code: "ASK {\n ?s ?p ?o .\n}",
149
+ group: "Query Types",
150
+ },
151
+ {
152
+ label: "FILTER",
153
+ code: "FILTER (?var > 100)",
154
+ group: "Patterns",
155
+ },
156
+ {
157
+ label: "OPTIONAL",
158
+ code: "OPTIONAL {\n ?s ?p ?o .\n}",
159
+ group: "Patterns",
160
+ },
161
+ ],
162
+ showSnippetsBar: true,
133
163
  };
134
164
  const requestConfig: PlainRequestConfig = {
135
165
  queryArgument: undefined, //undefined means: get query argument based on query mode
package/src/imgs.ts CHANGED
@@ -12,3 +12,9 @@ export var fullscreen =
12
12
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>';
13
13
  export var fullscreenExit =
14
14
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>';
15
+ export var format =
16
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 3v18h18v-2H5V3H3zm14 4V5l-4 4 4 4V9h4V7h-4zM7 9v2h10V9H7zm0 4v2h10v-2H7zm0 4v2h10v-2H7z"/></svg>';
17
+ export var snippet =
18
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>';
19
+ export var chevronDown =
20
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>';
package/src/index.ts CHANGED
@@ -11,11 +11,12 @@ import { drawSvgStringAsElement, addClass, removeClass } from "@matdata/yasgui-u
11
11
  import * as Sparql from "./sparql";
12
12
  import * as imgs from "./imgs";
13
13
  import * as Autocompleter from "./autocompleters";
14
- import { merge, escape } from "lodash-es";
14
+ import { merge, mergeWith, escape } from "lodash-es";
15
15
 
16
16
  import getDefaults from "./defaults";
17
17
  import CodeMirror from "./CodeMirror";
18
18
  import { YasqeAjaxConfig } from "./sparql";
19
+ import { spfmt } from "sparql-formatter";
19
20
 
20
21
  export interface Yasqe {
21
22
  on(eventName: "query", handler: (instance: Yasqe, req: Request, abortController?: AbortController) => void): void;
@@ -55,6 +56,8 @@ export class Yasqe extends CodeMirror {
55
56
  private fullscreenBtn: HTMLButtonElement | undefined;
56
57
  private isFullscreen: boolean = false;
57
58
  private resizeWrapper?: HTMLDivElement;
59
+ private snippetsBar?: HTMLDivElement;
60
+ private snippetsClickHandler?: (e: MouseEvent) => void;
58
61
  public rootEl: HTMLDivElement;
59
62
  public storage: YStorage;
60
63
  public config: Config;
@@ -65,7 +68,13 @@ export class Yasqe extends CodeMirror {
65
68
  this.rootEl = document.createElement("div");
66
69
  this.rootEl.className = "yasqe";
67
70
  parent.appendChild(this.rootEl);
68
- this.config = merge({}, Yasqe.defaults, conf);
71
+ // Use mergeWith to replace arrays instead of merging them by index
72
+ // This ensures that snippets: [] properly overrides default snippets
73
+ this.config = mergeWith({}, Yasqe.defaults, conf, (objValue: any, srcValue: any) => {
74
+ if (Array.isArray(srcValue)) {
75
+ return srcValue;
76
+ }
77
+ });
69
78
  //inherit codemirror props
70
79
  const cm = (CodeMirror as any)(this.rootEl, this.config);
71
80
  //Assign our functions to the cm object. This is needed, as some functions (like the ctrl-enter callback)
@@ -79,18 +88,36 @@ export class Yasqe extends CodeMirror {
79
88
  //Do some post processing
80
89
  this.storage = new YStorage(Yasqe.storageNamespace);
81
90
  this.drawButtons();
91
+ this.drawSnippetsBar();
82
92
  const storageId = this.getStorageId();
83
93
  // this.getWrapperElement
84
94
  if (storageId) {
85
95
  const persConf = this.storage.get<any>(storageId);
86
96
  if (persConf && typeof persConf === "string") {
87
- this.persistentConfig = { query: persConf, editorHeight: this.config.editorHeight }; // Migrate to object based localstorage
97
+ this.persistentConfig = { query: persConf, editorHeight: this.config.editorHeight };
88
98
  } else {
89
99
  this.persistentConfig = persConf;
90
100
  }
91
101
  if (!this.persistentConfig)
92
102
  this.persistentConfig = { query: this.getValue(), editorHeight: this.config.editorHeight };
103
+
104
+ // Ensure autoformatOnQuery is true by default
105
+ if (this.persistentConfig && typeof this.persistentConfig.autoformatOnQuery === "undefined") {
106
+ this.persistentConfig.autoformatOnQuery = true;
107
+ }
108
+
93
109
  if (this.persistentConfig && this.persistentConfig.query) this.setValue(this.persistentConfig.query);
110
+ } else {
111
+ // If no storage, ensure persistentConfig exists and autoformatOnQuery is true
112
+ if (!this.persistentConfig) {
113
+ this.persistentConfig = {
114
+ query: this.getValue(),
115
+ editorHeight: this.config.editorHeight,
116
+ autoformatOnQuery: true,
117
+ };
118
+ } else if (typeof this.persistentConfig.autoformatOnQuery === "undefined") {
119
+ this.persistentConfig.autoformatOnQuery = true;
120
+ }
94
121
  }
95
122
  this.config.autocompleters.forEach((c) => this.enableCompleter(c).then(() => {}, console.warn));
96
123
  if (this.config.consumeShareLink) {
@@ -99,6 +126,7 @@ export class Yasqe extends CodeMirror {
99
126
  window.addEventListener("hashchange", this.handleHashChange);
100
127
  }
101
128
  this.checkSyntax();
129
+ this.checkConstructVariables();
102
130
  // Size codemirror to the
103
131
  if (this.persistentConfig && this.persistentConfig.editorHeight) {
104
132
  this.getWrapperElement().style.height = this.persistentConfig.editorHeight;
@@ -115,6 +143,7 @@ export class Yasqe extends CodeMirror {
115
143
  };
116
144
  private handleChange() {
117
145
  this.checkSyntax();
146
+ this.checkConstructVariables();
118
147
  this.updateQueryButton();
119
148
  }
120
149
  private handleBlur() {
@@ -123,6 +152,7 @@ export class Yasqe extends CodeMirror {
123
152
  private handleChanges() {
124
153
  // e.g. handle blur
125
154
  this.checkSyntax();
155
+ this.checkConstructVariables();
126
156
  this.updateQueryButton();
127
157
  }
128
158
  private handleCursorActivity() {
@@ -347,6 +377,23 @@ export class Yasqe extends CodeMirror {
347
377
  this.updateQueryButton();
348
378
  }
349
379
 
380
+ /**
381
+ * Draw format btn
382
+ */
383
+ if (this.config.showFormatButton) {
384
+ const formatBtn = document.createElement("button");
385
+ addClass(formatBtn, "yasqe_formatButton");
386
+ const formatIcon = drawSvgStringAsElement(imgs.format);
387
+ addClass(formatIcon, "formatIcon");
388
+ formatBtn.appendChild(formatIcon);
389
+ formatBtn.onclick = () => {
390
+ this.format();
391
+ };
392
+ formatBtn.title = "Format query (Shift+Ctrl+F)";
393
+ formatBtn.setAttribute("aria-label", "Format query");
394
+ buttons.appendChild(formatBtn);
395
+ }
396
+
350
397
  /**
351
398
  * Draw fullscreen btn
352
399
  */
@@ -379,6 +426,165 @@ export class Yasqe extends CodeMirror {
379
426
  public getIsFullscreen() {
380
427
  return this.isFullscreen;
381
428
  }
429
+
430
+ private insertSnippet(code: string) {
431
+ const doc = this.getDoc();
432
+ const cursor = doc.getCursor();
433
+ doc.replaceRange(code, cursor);
434
+ // Move cursor to end of inserted code
435
+ const lines = code.split("\n");
436
+ const lastLine = lines[lines.length - 1];
437
+ doc.setCursor({
438
+ line: cursor.line + lines.length - 1,
439
+ ch: lines.length === 1 ? cursor.ch + lastLine.length : lastLine.length,
440
+ });
441
+ this.focus();
442
+ }
443
+
444
+ private drawSnippetsBar() {
445
+ // Check if snippets bar should be shown
446
+ const shouldShow =
447
+ this.config.showSnippetsBar &&
448
+ (this.persistentConfig?.showSnippetsBar === undefined || this.persistentConfig.showSnippetsBar) &&
449
+ this.config.snippets.length > 0;
450
+
451
+ if (!shouldShow) {
452
+ // Remove existing bar if present
453
+ if (this.snippetsBar) {
454
+ this.snippetsBar.remove();
455
+ this.snippetsBar = undefined;
456
+ }
457
+ return;
458
+ }
459
+
460
+ // Create snippets bar if it doesn't exist
461
+ if (!this.snippetsBar) {
462
+ this.snippetsBar = document.createElement("div");
463
+ addClass(this.snippetsBar, "yasqe_snippetsBar");
464
+ // Insert before the CodeMirror wrapper element
465
+ const cmWrapper = this.getWrapperElement();
466
+ cmWrapper.parentElement?.insertBefore(this.snippetsBar, cmWrapper);
467
+ }
468
+
469
+ // Clear existing content
470
+ this.snippetsBar.innerHTML = "";
471
+
472
+ const snippets = this.config.snippets;
473
+
474
+ // If 10 or fewer snippets, show all as buttons
475
+ if (snippets.length <= 10) {
476
+ snippets.forEach((snippet) => {
477
+ const btn = document.createElement("button");
478
+ addClass(btn, "yasqe_snippetButton");
479
+ btn.textContent = snippet.label;
480
+ btn.title = snippet.code;
481
+ btn.setAttribute("aria-label", `Insert ${snippet.label} snippet`);
482
+ btn.onclick = () => this.insertSnippet(snippet.code);
483
+ this.snippetsBar!.appendChild(btn);
484
+ });
485
+ } else {
486
+ // Group snippets by group property
487
+ const grouped: { [group: string]: Snippet[] } = {};
488
+ const ungrouped: Snippet[] = [];
489
+
490
+ snippets.forEach((snippet) => {
491
+ if (snippet.group) {
492
+ if (!grouped[snippet.group]) grouped[snippet.group] = [];
493
+ grouped[snippet.group].push(snippet);
494
+ } else {
495
+ ungrouped.push(snippet);
496
+ }
497
+ });
498
+
499
+ // Create dropdown for each group
500
+ Object.keys(grouped).forEach((groupName) => {
501
+ const dropdown = document.createElement("div");
502
+ addClass(dropdown, "yasqe_snippetDropdown");
503
+
504
+ const dropdownBtn = document.createElement("button");
505
+ addClass(dropdownBtn, "yasqe_snippetDropdownButton");
506
+ dropdownBtn.textContent = groupName + " ";
507
+ const chevron = drawSvgStringAsElement(imgs.chevronDown);
508
+ addClass(chevron, "chevronIcon");
509
+ dropdownBtn.appendChild(chevron);
510
+ dropdownBtn.setAttribute("aria-label", `${groupName} snippets`);
511
+ dropdownBtn.setAttribute("aria-expanded", "false");
512
+
513
+ const dropdownContent = document.createElement("div");
514
+ addClass(dropdownContent, "yasqe_snippetDropdownContent");
515
+ dropdownContent.setAttribute("role", "menu");
516
+
517
+ grouped[groupName].forEach((snippet) => {
518
+ const item = document.createElement("button");
519
+ addClass(item, "yasqe_snippetDropdownItem");
520
+ item.textContent = snippet.label;
521
+ item.title = snippet.code;
522
+ item.setAttribute("role", "menuitem");
523
+ item.onclick = () => {
524
+ this.insertSnippet(snippet.code);
525
+ dropdownContent.style.display = "none";
526
+ dropdownBtn.setAttribute("aria-expanded", "false");
527
+ };
528
+ dropdownContent.appendChild(item);
529
+ });
530
+
531
+ dropdownBtn.onclick = (e) => {
532
+ e.stopPropagation();
533
+ const isOpen = dropdownContent.style.display === "block";
534
+ // Close all other dropdowns
535
+ const allDropdowns = this.snippetsBar!.querySelectorAll(".yasqe_snippetDropdownContent");
536
+ allDropdowns.forEach((dd) => {
537
+ (dd as HTMLElement).style.display = "none";
538
+ });
539
+ const allButtons = this.snippetsBar!.querySelectorAll(".yasqe_snippetDropdownButton");
540
+ allButtons.forEach((btn) => btn.setAttribute("aria-expanded", "false"));
541
+
542
+ // Toggle this dropdown
543
+ if (!isOpen) {
544
+ dropdownContent.style.display = "block";
545
+ dropdownBtn.setAttribute("aria-expanded", "true");
546
+ }
547
+ };
548
+
549
+ dropdown.appendChild(dropdownBtn);
550
+ dropdown.appendChild(dropdownContent);
551
+ this.snippetsBar!.appendChild(dropdown);
552
+ });
553
+
554
+ // Add ungrouped snippets as individual buttons
555
+ ungrouped.forEach((snippet) => {
556
+ const btn = document.createElement("button");
557
+ addClass(btn, "yasqe_snippetButton");
558
+ btn.textContent = snippet.label;
559
+ btn.title = snippet.code;
560
+ btn.setAttribute("aria-label", `Insert ${snippet.label} snippet`);
561
+ btn.onclick = () => this.insertSnippet(snippet.code);
562
+ this.snippetsBar!.appendChild(btn);
563
+ });
564
+ }
565
+
566
+ // Set up click handler for closing dropdowns when clicking outside
567
+ // Remove any existing handler first
568
+ if (this.snippetsClickHandler) {
569
+ document.removeEventListener("click", this.snippetsClickHandler);
570
+ }
571
+
572
+ // Create and store the handler
573
+ this.snippetsClickHandler = (e: MouseEvent) => {
574
+ if (this.snippetsBar && !this.snippetsBar.contains(e.target as Node)) {
575
+ const allDropdowns = this.snippetsBar.querySelectorAll(".yasqe_snippetDropdownContent");
576
+ allDropdowns.forEach((dd) => {
577
+ (dd as HTMLElement).style.display = "none";
578
+ });
579
+ const allButtons = this.snippetsBar.querySelectorAll(".yasqe_snippetDropdownButton");
580
+ allButtons.forEach((btn) => btn.setAttribute("aria-expanded", "false"));
581
+ }
582
+ };
583
+
584
+ // Add the handler
585
+ document.addEventListener("click", this.snippetsClickHandler);
586
+ }
587
+
382
588
  private drawResizer() {
383
589
  if (this.resizeWrapper) return;
384
590
  this.resizeWrapper = document.createElement("div");
@@ -643,6 +849,32 @@ export class Yasqe extends CodeMirror {
643
849
  }
644
850
  });
645
851
  }
852
+
853
+ public formatQuery() {
854
+ try {
855
+ const currentQuery = this.getValue();
856
+ const formatted = spfmt.format(currentQuery);
857
+ this.setValue(formatted);
858
+ // Collapse prefixes after formatting
859
+ this.collapsePrefixes(true);
860
+ } catch (error) {
861
+ console.warn(
862
+ "Failed to format SPARQL query using sparql-formatter. This may be due to syntax errors in the query. Falling back to legacy formatter.",
863
+ error,
864
+ );
865
+ // If formatting fails, fall back to the built-in autoformat
866
+ this.autoformat();
867
+ }
868
+ }
869
+
870
+ public format() {
871
+ const formatterType = this.persistentConfig?.formatterType || "sparql-formatter";
872
+ if (formatterType === "legacy") {
873
+ this.autoformat();
874
+ } else {
875
+ this.formatQuery();
876
+ }
877
+ }
646
878
  //values in the form of {?var: 'value'}, or [{?var: 'value'}]
647
879
  public getQueryWithValues(values: string | { [varName: string]: string } | Array<{ [varName: string]: string }>) {
648
880
  if (!values) return this.getValue();
@@ -709,6 +941,7 @@ export class Yasqe extends CodeMirror {
709
941
  public setCheckSyntaxErrors(isEnabled: boolean) {
710
942
  this.config.syntaxErrorCheck = isEnabled;
711
943
  this.checkSyntax();
944
+ this.checkConstructVariables();
712
945
  }
713
946
  public checkSyntax() {
714
947
  this.queryValid = true;
@@ -765,6 +998,77 @@ export class Yasqe extends CodeMirror {
765
998
  }
766
999
  }
767
1000
  }
1001
+
1002
+ public setCheckConstructVariables(isEnabled: boolean) {
1003
+ this.config.checkConstructVariables = isEnabled;
1004
+ if (!isEnabled) {
1005
+ // Clear any existing warnings when disabled
1006
+ this.clearGutter("gutterConstructWarning");
1007
+ } else {
1008
+ this.checkConstructVariables();
1009
+ }
1010
+ }
1011
+
1012
+ public checkConstructVariables() {
1013
+ // Clear any existing warnings first
1014
+ this.clearGutter("gutterConstructWarning");
1015
+
1016
+ // Only check if enabled, query is valid, and it's a CONSTRUCT query
1017
+ if (!this.config.checkConstructVariables || !this.queryValid || this.getQueryType() !== "CONSTRUCT") {
1018
+ return;
1019
+ }
1020
+
1021
+ // Get the final state after parsing the entire query
1022
+ const lastLine = this.getDoc().lastLine();
1023
+ const token: Token = this.getTokenAt({ line: lastLine, ch: this.getDoc().getLine(lastLine).length }, true);
1024
+
1025
+ const state = token.state as TokenizerState;
1026
+
1027
+ // Check for undefined variables in CONSTRUCT template
1028
+ const undefinedVars: string[] = [];
1029
+ for (const varName in state.constructVariables) {
1030
+ if (!state.whereVariables[varName]) {
1031
+ undefinedVars.push(varName);
1032
+ }
1033
+ }
1034
+
1035
+ if (undefinedVars.length === 0) {
1036
+ return;
1037
+ }
1038
+
1039
+ // Find lines where undefined variables are used in CONSTRUCT template
1040
+ // Note: This iterates through all lines but filters by inConstructTemplate flag
1041
+ // For large queries, this could be optimized by tracking line ranges during tokenization
1042
+ for (let l = 0; l < this.getDoc().lineCount(); ++l) {
1043
+ const lineToken: Token = this.getTokenAt({ line: l, ch: this.getDoc().getLine(l).length }, true);
1044
+ const lineState = lineToken.state as TokenizerState;
1045
+
1046
+ // Only mark variables in the CONSTRUCT template
1047
+ if (lineState.queryType === "CONSTRUCT" && lineState.inConstructTemplate) {
1048
+ const line = this.getDoc().getLine(l);
1049
+ // Check if this line contains any undefined variable (use word boundary to avoid partial matches)
1050
+ for (const undefinedVar of undefinedVars) {
1051
+ // Escape special regex characters in variable name
1052
+ // Use negative lookbehind/lookahead to ensure we match the full variable name
1053
+ // Variables can be followed by whitespace, punctuation, or end of line
1054
+ const escapedVar = undefinedVar.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1055
+ const varRegex = new RegExp(`${escapedVar}(?![a-zA-Z0-9_])`);
1056
+ if (varRegex.test(line)) {
1057
+ const warningEl = drawSvgStringAsElement(imgs.warning);
1058
+ warningEl.className = "constructVariableWarning";
1059
+ tooltip(
1060
+ this,
1061
+ warningEl,
1062
+ escape(`Variable ${undefinedVar} is used in CONSTRUCT but not defined in WHERE clause`),
1063
+ );
1064
+ this.setGutterMarker(l, "gutterConstructWarning", warningEl);
1065
+ break; // Only one marker per line
1066
+ }
1067
+ }
1068
+ }
1069
+ }
1070
+ }
1071
+
768
1072
  /**
769
1073
  * Token management
770
1074
  */
@@ -818,6 +1122,31 @@ export class Yasqe extends CodeMirror {
818
1122
  }
819
1123
  }
820
1124
 
1125
+ /**
1126
+ * Snippets management
1127
+ */
1128
+ public setSnippetsBarVisible(visible: boolean) {
1129
+ if (!this.persistentConfig) {
1130
+ this.persistentConfig = {
1131
+ query: this.getValue(),
1132
+ editorHeight: this.config.editorHeight,
1133
+ showSnippetsBar: visible,
1134
+ };
1135
+ } else {
1136
+ this.persistentConfig.showSnippetsBar = visible;
1137
+ }
1138
+ this.saveQuery();
1139
+ this.drawSnippetsBar();
1140
+ }
1141
+
1142
+ public getSnippetsBarVisible(): boolean {
1143
+ return (
1144
+ this.config.showSnippetsBar &&
1145
+ (this.persistentConfig?.showSnippetsBar === undefined || this.persistentConfig.showSnippetsBar) &&
1146
+ this.config.snippets.length > 0
1147
+ );
1148
+ }
1149
+
821
1150
  /**
822
1151
  * Autocompleter management
823
1152
  */
@@ -875,6 +1204,10 @@ export class Yasqe extends CodeMirror {
875
1204
  */
876
1205
  public query(config?: Sparql.YasqeAjaxConfig) {
877
1206
  if (this.config.queryingDisabled) return Promise.reject("Querying is disabled.");
1207
+ // Auto-format query before execution if enabled in persistent config
1208
+ if (this.persistentConfig?.autoformatOnQuery) {
1209
+ this.format();
1210
+ }
878
1211
  // Abort previous request
879
1212
  this.abortQuery();
880
1213
  return Sparql.executeQuery(this, config);
@@ -929,6 +1262,10 @@ export class Yasqe extends CodeMirror {
929
1262
  this.unregisterEventListeners();
930
1263
  this.resizeWrapper?.removeEventListener("mousedown", this.initDrag, false);
931
1264
  this.resizeWrapper?.removeEventListener("dblclick", this.expandEditor);
1265
+ if (this.snippetsClickHandler) {
1266
+ document.removeEventListener("click", this.snippetsClickHandler);
1267
+ this.snippetsClickHandler = undefined;
1268
+ }
932
1269
  for (const autocompleter in this.autocompleters) {
933
1270
  this.disableCompleter(autocompleter);
934
1271
  }
@@ -1044,6 +1381,12 @@ export type PlainRequestConfig = {
1044
1381
  export type PartialConfig = {
1045
1382
  [P in keyof Config]?: Config[P] extends object ? Partial<Config[P]> : Config[P];
1046
1383
  };
1384
+
1385
+ export interface Snippet {
1386
+ label: string;
1387
+ code: string;
1388
+ group?: string;
1389
+ }
1047
1390
  export interface Config extends Partial<CodeMirror.EditorConfiguration> {
1048
1391
  mode: string;
1049
1392
  collapsePrefixesOnLoad: boolean;
@@ -1081,10 +1424,17 @@ export interface Config extends Partial<CodeMirror.EditorConfiguration> {
1081
1424
  editorHeight: string;
1082
1425
  queryingDisabled: string | undefined; // The string will be the message displayed when hovered
1083
1426
  prefixCcApi: string; // the suggested default prefixes URL API getter
1427
+ showFormatButton: boolean; // Show a button to format the query
1428
+ checkConstructVariables: boolean; // Check for undefined variables in CONSTRUCT queries
1429
+ snippets: Snippet[]; // Code snippets to show in the snippets bar
1430
+ showSnippetsBar: boolean; // Show the snippets bar
1084
1431
  }
1085
1432
  export interface PersistentConfig {
1086
1433
  query: string;
1087
1434
  editorHeight: string;
1435
+ formatterType?: "sparql-formatter" | "legacy"; // Which formatter to use
1436
+ autoformatOnQuery?: boolean; // Auto-format query on execution
1437
+ showSnippetsBar?: boolean; // User preference for snippets bar visibility
1088
1438
  }
1089
1439
  // export var _Yasqe = _Yasqe;
1090
1440
 
package/src/prefixFold.ts CHANGED
@@ -15,6 +15,7 @@ export function findFirstPrefixLine(yasqe: Yasqe) {
15
15
 
16
16
  export function findFirstPrefix(yasqe: Yasqe, line: number, startFromCharIndex = 0, lineText?: string) {
17
17
  if (!lineText) lineText = yasqe.getDoc().getLine(line);
18
+ if (!lineText) return undefined;
18
19
  lineText = lineText.toUpperCase();
19
20
  const charIndex = lineText.indexOf(PREFIX_KEYWORD, startFromCharIndex);
20
21
  if (charIndex >= 0) {
@@ -30,7 +30,10 @@ export function removePrefixes(yasqe: Yasqe, prefixes: Prefixes) {
30
30
  yasqe.setValue(
31
31
  yasqe
32
32
  .getValue()
33
- .replace(new RegExp("PREFIX\\s*" + pref + ":\\s*" + escapeRegex("<" + prefixes[pref] + ">") + "\\s*", "ig"), "")
33
+ .replace(
34
+ new RegExp("PREFIX\\s*" + pref + ":\\s*" + escapeRegex("<" + prefixes[pref] + ">") + "\\s*", "ig"),
35
+ "",
36
+ ),
34
37
  );
35
38
  }
36
39
  yasqe.collapsePrefixes(false);
@@ -48,7 +51,7 @@ export function getPrefixesFromQuery(yasqe: Yasqe): Token["state"]["prefixes"] {
48
51
  //as https://github.com/TriplyDB/YASGUI/issues/84)
49
52
  return yasqe.getTokenAt(
50
53
  { line: yasqe.getDoc().lastLine(), ch: yasqe.getDoc().getLine(yasqe.getDoc().lastLine()).length },
51
- true
54
+ true,
52
55
  ).state.prefixes;
53
56
  }
54
57