@matdata/yasqe 5.9.0 → 5.10.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/yasqe",
3
3
  "description": "Yet Another SPARQL Query Editor",
4
- "version": "5.9.0",
4
+ "version": "5.10.0",
5
5
  "main": "build/yasqe.min.js",
6
6
  "types": "build/ts/src/index.d.ts",
7
7
  "license": "MIT",
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Share Functionality Tests
3
+ */
4
+
5
+ import { describe, it } from "mocha";
6
+ import { expect } from "chai";
7
+
8
+ describe("Share Functionality", () => {
9
+ describe("Shell String Escaping", () => {
10
+ it("should escape single quotes for shell commands", () => {
11
+ const input = "test'value";
12
+ const escaped = input.replace(/'/g, "'\\''");
13
+ expect(escaped).to.equal("test'\\''value");
14
+ });
15
+
16
+ it("should handle strings with multiple single quotes", () => {
17
+ const input = "it's a 'test'";
18
+ const escaped = input.replace(/'/g, "'\\''");
19
+ expect(escaped).to.equal("it'\\''s a '\\''test'\\''");
20
+ });
21
+ });
22
+
23
+ describe("PowerShell String Escaping", () => {
24
+ it("should escape backticks, double quotes, and dollar signs", () => {
25
+ const input = 'test"value$var`tick';
26
+ const escaped = input.replace(/`/g, "``").replace(/"/g, '`"').replace(/\$/g, "`$");
27
+ expect(escaped).to.equal('test`"value`$var``tick');
28
+ });
29
+
30
+ it("should handle strings with multiple special characters", () => {
31
+ const input = '$test`"value"';
32
+ const escaped = input.replace(/`/g, "``").replace(/"/g, '`"').replace(/\$/g, "`$");
33
+ expect(escaped).to.equal('`$test```"value`"');
34
+ });
35
+ });
36
+
37
+ describe("URL Normalization", () => {
38
+ it("should detect absolute URLs", () => {
39
+ const url = "https://example.com/sparql";
40
+ expect(url.indexOf("http")).to.equal(0);
41
+ });
42
+
43
+ it("should detect relative URLs", () => {
44
+ const url = "/sparql";
45
+ expect(url.indexOf("http")).to.equal(-1);
46
+ expect(url.indexOf("/")).to.equal(0);
47
+ });
48
+
49
+ it("should handle relative path normalization", () => {
50
+ const pathname = "/app/editor";
51
+ const relativePath = "sparql";
52
+
53
+ let basePath = pathname;
54
+ if (!basePath.endsWith("/")) {
55
+ const lastSlashIndex = basePath.lastIndexOf("/");
56
+ basePath = lastSlashIndex >= 0 ? basePath.substring(0, lastSlashIndex + 1) : "/";
57
+ }
58
+ const result = basePath + relativePath;
59
+
60
+ expect(result).to.equal("/app/sparql");
61
+ });
62
+
63
+ it("should handle pathname ending with slash", () => {
64
+ const pathname = "/app/";
65
+ const relativePath = "sparql";
66
+
67
+ let basePath = pathname;
68
+ if (!basePath.endsWith("/")) {
69
+ const lastSlashIndex = basePath.lastIndexOf("/");
70
+ basePath = lastSlashIndex >= 0 ? basePath.substring(0, lastSlashIndex + 1) : "/";
71
+ }
72
+ const result = basePath + relativePath;
73
+
74
+ expect(result).to.equal("/app/sparql");
75
+ });
76
+ });
77
+
78
+ describe("Command Generation Format", () => {
79
+ it("should format cURL commands with proper line breaks", () => {
80
+ const segments = [
81
+ "curl",
82
+ "'https://example.com/sparql'",
83
+ "--data",
84
+ "'query=SELECT'",
85
+ "-X",
86
+ "POST",
87
+ "-H",
88
+ "'Authorization: Bearer token'",
89
+ ];
90
+ const curlString = segments.join(" \\\n ");
91
+
92
+ expect(curlString).to.include("curl");
93
+ expect(curlString).to.include("\\\n");
94
+ expect(curlString).to.include("https://example.com/sparql");
95
+ });
96
+
97
+ it("should format PowerShell commands with proper structure", () => {
98
+ const lines = [
99
+ "$params = @{",
100
+ ' Uri = "https://example.com/sparql"',
101
+ ' Method = "Post"',
102
+ " Headers = @{",
103
+ ' "Accept" = "application/sparql-results+json"',
104
+ ' "Authorization" = "Bearer token"',
105
+ " }",
106
+ ' ContentType = "application/x-www-form-urlencoded"',
107
+ ' Body = "query=SELECT"',
108
+ ' OutFile = "result.json"',
109
+ "}",
110
+ "",
111
+ "Invoke-WebRequest @params",
112
+ ];
113
+ const psString = lines.join("\n");
114
+
115
+ expect(psString).to.include("$params");
116
+ expect(psString).to.include("Invoke-WebRequest");
117
+ expect(psString).to.include("Headers");
118
+ expect(psString).to.include("OutFile");
119
+ expect(psString).to.include("Accept");
120
+ });
121
+
122
+ it("should format wget commands with proper line breaks", () => {
123
+ const segments = [
124
+ "wget",
125
+ "'https://example.com/sparql'",
126
+ "--body-data",
127
+ "'query=SELECT'",
128
+ "--method",
129
+ "POST",
130
+ "--header",
131
+ "'Authorization: Bearer token'",
132
+ "-O -",
133
+ ];
134
+ const wgetString = segments.join(" \\\n ");
135
+
136
+ expect(wgetString).to.include("wget");
137
+ expect(wgetString).to.include("\\\n");
138
+ expect(wgetString).to.include("--body-data");
139
+ });
140
+ });
141
+
142
+ describe("URL Normalization", () => {
143
+ it("should handle absolute URLs", () => {
144
+ const url = "https://example.com/sparql";
145
+ expect(url.indexOf("http")).to.equal(0);
146
+ });
147
+
148
+ it("should detect relative URLs", () => {
149
+ const url = "/sparql";
150
+ expect(url.indexOf("http")).to.equal(-1);
151
+ expect(url.indexOf("/")).to.equal(0);
152
+ });
153
+
154
+ it("should detect relative paths", () => {
155
+ const url = "sparql";
156
+ expect(url.indexOf("http")).to.equal(-1);
157
+ expect(url.indexOf("/")).to.not.equal(0);
158
+ });
159
+ });
160
+
161
+ describe("Authentication Detection Logic", () => {
162
+ it("should detect Authorization header", () => {
163
+ const headers = { Authorization: "Bearer token" };
164
+ expect(headers["Authorization"]).to.exist;
165
+ });
166
+
167
+ it("should detect API key headers by name", () => {
168
+ const headerName = "X-API-Key";
169
+ const lowerHeader = headerName.toLowerCase();
170
+ expect(lowerHeader).to.include("key");
171
+ });
172
+
173
+ it("should detect token headers by name", () => {
174
+ const headerName = "X-Auth-Token";
175
+ const lowerHeader = headerName.toLowerCase();
176
+ expect(lowerHeader).to.include("token");
177
+ });
178
+
179
+ it("should detect auth headers by name", () => {
180
+ const headerName = "X-Custom-Auth";
181
+ const lowerHeader = headerName.toLowerCase();
182
+ expect(lowerHeader).to.include("auth");
183
+ });
184
+
185
+ it("should not detect non-auth headers", () => {
186
+ const headerName = "Content-Type";
187
+ const lowerHeader = headerName.toLowerCase();
188
+ expect(lowerHeader).to.not.include("key");
189
+ expect(lowerHeader).to.not.include("token");
190
+ expect(lowerHeader).to.not.include("auth");
191
+ });
192
+ });
193
+
194
+ describe("String Escaping", () => {
195
+ it("should escape double quotes in PowerShell", () => {
196
+ const value = 'test"value';
197
+ const escaped = value.replace(/"/g, '`"');
198
+ expect(escaped).to.equal('test`"value');
199
+ });
200
+
201
+ it("should handle single quotes in shell commands", () => {
202
+ const value = "test'value";
203
+ const wrapped = `'${value}'`;
204
+ expect(wrapped).to.equal("'test'value'");
205
+ });
206
+ });
207
+
208
+ describe("HTTP Method Handling", () => {
209
+ it("should use POST for SPARQL updates", () => {
210
+ const queryMode: string = "update";
211
+ const method = queryMode === "update" ? "POST" : "GET";
212
+ expect(method).to.equal("POST");
213
+ });
214
+
215
+ it("should default to configured method for SPARQL queries", () => {
216
+ const queryMode: string = "query";
217
+ const method = queryMode === "update" ? "POST" : "GET";
218
+ expect(method).to.equal("GET");
219
+ });
220
+ });
221
+
222
+ describe("Query String Formatting", () => {
223
+ it("should format query parameters", () => {
224
+ const params = { query: "SELECT * WHERE { ?s ?p ?o }", format: "json" };
225
+ const keys = Object.keys(params);
226
+ expect(keys).to.include("query");
227
+ expect(keys).to.include("format");
228
+ });
229
+
230
+ it("should handle URL encoding", () => {
231
+ const query = "SELECT * WHERE { ?s ?p ?o }";
232
+ const encoded = encodeURIComponent(query);
233
+ expect(encoded).to.include("SELECT");
234
+ expect(encoded).to.not.include(" ");
235
+ });
236
+ });
237
+ });
package/src/index.ts CHANGED
@@ -18,6 +18,12 @@ import CodeMirror from "./CodeMirror";
18
18
  import { YasqeAjaxConfig } from "./sparql";
19
19
  import { spfmt } from "sparql-formatter";
20
20
 
21
+ // Toast notification timing constants
22
+ const TOAST_DEFAULT_DURATION = 3000; // 3 seconds
23
+ const TOAST_WARNING_DURATION = 4000; // 4 seconds for warnings
24
+ const TOAST_WARNING_DELAY = 500; // Delay before showing auth warning
25
+ const TOAST_FADEOUT_DURATION = 300; // Fade out animation duration
26
+
21
27
  export interface Yasqe {
22
28
  on(eventName: "query", handler: (instance: Yasqe, req: Request, abortController?: AbortController) => void): void;
23
29
  off(eventName: "query", handler: (instance: Yasqe, req: Request, abortController?: AbortController) => void): void;
@@ -262,87 +268,217 @@ export class Yasqe extends CodeMirror {
262
268
  let popup: HTMLDivElement | undefined = document.createElement("div");
263
269
  popup.className = "yasqe_sharePopup";
264
270
  buttons.appendChild(popup);
265
- document.body.addEventListener(
266
- "click",
267
- (event) => {
268
- if (popup && event.target !== popup && !popup.contains(<any>event.target)) {
269
- popup.remove();
270
- popup = undefined;
271
+
272
+ // Toast notification element for warnings
273
+ let toastElement: HTMLDivElement | undefined;
274
+ let toastTimeout: number | undefined;
275
+
276
+ const showToast = (
277
+ message: string,
278
+ duration: number = TOAST_DEFAULT_DURATION,
279
+ type: "info" | "warning" = "info",
280
+ ) => {
281
+ // Remove existing toast if any
282
+ if (toastElement) {
283
+ toastElement.remove();
284
+ }
285
+ // Clear existing timeout
286
+ if (toastTimeout !== undefined) {
287
+ clearTimeout(toastTimeout);
288
+ }
289
+
290
+ toastElement = document.createElement("div");
291
+ toastElement.className = type === "warning" ? "yasqe_toast yasqe_toast-warning" : "yasqe_toast";
292
+
293
+ // Add warning icon for warning toasts
294
+ if (type === "warning") {
295
+ const iconWrapper = document.createElement("span");
296
+ iconWrapper.className = "yasqe_toast-icon";
297
+ const icon = drawSvgStringAsElement(imgs.warning);
298
+ iconWrapper.appendChild(icon);
299
+ toastElement.appendChild(iconWrapper);
300
+ }
301
+
302
+ const messageSpan = document.createElement("span");
303
+ messageSpan.className = "yasqe_toast-message";
304
+ messageSpan.textContent = message;
305
+ toastElement.appendChild(messageSpan);
306
+
307
+ document.body.appendChild(toastElement);
308
+
309
+ // Auto-remove after duration
310
+ toastTimeout = window.setTimeout(() => {
311
+ if (toastElement) {
312
+ toastElement.classList.add("yasqe_toast-fadeout");
313
+ setTimeout(() => {
314
+ toastElement?.remove();
315
+ toastElement = undefined;
316
+ }, TOAST_FADEOUT_DURATION);
271
317
  }
272
- },
273
- true,
274
- );
275
- var input = document.createElement("input");
276
- input.type = "text";
277
- input.value = this.config.createShareableLink(this);
278
-
279
- input.onfocus = function () {
280
- input.select();
318
+ }, duration);
281
319
  };
282
- // Work around Chrome's little problem
283
- input.onmouseup = function () {
284
- // $this.unbind("mouseup");
285
- return false;
320
+
321
+ // Create event listener that can be removed
322
+ const closePopupHandler = (event: MouseEvent) => {
323
+ if (popup && event.target !== popup && !popup.contains(<any>event.target)) {
324
+ popup.remove();
325
+ popup = undefined;
326
+ // Clean up toast when popup closes
327
+ if (toastElement) {
328
+ toastElement.remove();
329
+ toastElement = undefined;
330
+ }
331
+ if (toastTimeout !== undefined) {
332
+ clearTimeout(toastTimeout);
333
+ }
334
+ // Remove this event listener to prevent memory leak
335
+ document.body.removeEventListener("click", closePopupHandler, true);
336
+ }
286
337
  };
338
+
339
+ document.body.addEventListener("click", closePopupHandler, true);
340
+
287
341
  popup.innerHTML = "";
288
342
 
289
- var inputWrapper = document.createElement("div");
290
- inputWrapper.className = "inputWrapper";
343
+ // Helper function to copy text to clipboard
344
+ const copyToClipboard = async (text: string, buttonText: string, hasAuth: boolean = false) => {
345
+ try {
346
+ // Check if Clipboard API is available
347
+ if (!navigator.clipboard || !navigator.clipboard.writeText) {
348
+ // Fallback for older browsers or non-secure contexts
349
+ const textArea = document.createElement("textarea");
350
+ textArea.value = text;
351
+ textArea.style.position = "fixed";
352
+ textArea.style.left = "-999999px";
353
+ document.body.appendChild(textArea);
354
+ textArea.select();
355
+ try {
356
+ document.execCommand("copy");
357
+ document.body.removeChild(textArea);
358
+ showToast(`${buttonText} copied to clipboard!`);
359
+ } catch (err) {
360
+ document.body.removeChild(textArea);
361
+ throw new Error("Copy command not supported");
362
+ }
363
+ } else {
364
+ await navigator.clipboard.writeText(text);
365
+ showToast(`${buttonText} copied to clipboard!`);
366
+ }
291
367
 
292
- inputWrapper.appendChild(input);
368
+ // Show warning if credentials are included
369
+ if (hasAuth) {
370
+ setTimeout(() => {
371
+ showToast(
372
+ "Warning: Authentication credentials included in copied content",
373
+ TOAST_WARNING_DURATION,
374
+ "warning",
375
+ );
376
+ }, TOAST_WARNING_DELAY);
377
+ }
378
+ } catch (err) {
379
+ console.error("Failed to copy to clipboard:", err);
380
+ showToast("Failed to copy to clipboard. Please copy manually.", 2000);
381
+ }
382
+ };
293
383
 
294
- popup.appendChild(inputWrapper);
384
+ // Create title
385
+ const title = document.createElement("div");
386
+ title.className = "yasqe_sharePopup_title";
387
+ title.textContent = "Share Query";
388
+ popup.appendChild(title);
389
+
390
+ // Create button container
391
+ const buttonContainer = document.createElement("div");
392
+ buttonContainer.className = "yasqe_sharePopup_buttons";
393
+ popup.appendChild(buttonContainer);
394
+
395
+ // URL button
396
+ const urlBtn = document.createElement("button");
397
+ urlBtn.innerText = "Copy URL";
398
+ urlBtn.className = "yasqe_btn yasqe_btn-sm yasqe_shareBtn";
399
+ buttonContainer.appendChild(urlBtn);
400
+ urlBtn.onclick = async () => {
401
+ const url = this.config.createShareableLink(this);
402
+ await copyToClipboard(url, "URL");
403
+ };
295
404
 
296
- // We need to track which buttons are drawn here since the two implementations don't play nice together
297
- const popupInputButtons: HTMLButtonElement[] = [];
405
+ // URL Shorten button (if configured)
298
406
  const createShortLink = this.config.createShortLink;
299
407
  if (createShortLink) {
300
- popup.className = popup.className += " enableShort";
301
- const shortBtn = document.createElement("button");
302
- popupInputButtons.push(shortBtn);
303
- shortBtn.innerHTML = "Shorten";
304
- shortBtn.className = "yasqe_btn yasqe_btn-sm shorten";
305
- popup.appendChild(shortBtn);
306
- shortBtn.onclick = () => {
307
- popupInputButtons.forEach((button) => (button.disabled = true));
308
- createShortLink(this, input.value).then(
309
- (value) => {
310
- input.value = value;
311
- input.focus();
312
- },
313
- (err) => {
314
- const errSpan = document.createElement("span");
315
- errSpan.className = "shortlinkErr";
316
- // Throwing a string or an object should work
317
- let textContent = "An error has occurred";
318
- if (typeof err === "string" && err.length !== 0) {
319
- textContent = err;
320
- } else if (err.message && err.message.length !== 0) {
321
- textContent = err.message;
322
- }
323
- errSpan.textContent = textContent;
324
- input.replaceWith(errSpan);
325
- },
326
- );
408
+ const shortenBtn = document.createElement("button");
409
+ shortenBtn.innerText = "Shorten URL";
410
+ shortenBtn.className = "yasqe_btn yasqe_btn-sm yasqe_shareBtn";
411
+ buttonContainer.appendChild(shortenBtn);
412
+ shortenBtn.onclick = async () => {
413
+ shortenBtn.disabled = true;
414
+ shortenBtn.innerText = "Shortening...";
415
+ try {
416
+ const longUrl = this.config.createShareableLink(this);
417
+ const shortUrl = await createShortLink(this, longUrl);
418
+ await copyToClipboard(shortUrl, "Shortened URL");
419
+ shortenBtn.innerText = "Shorten URL";
420
+ shortenBtn.disabled = false;
421
+ } catch (err) {
422
+ shortenBtn.innerText = "Shorten URL";
423
+ shortenBtn.disabled = false;
424
+ let errorMsg = "Failed to shorten URL";
425
+ if (typeof err === "string" && err.length !== 0) {
426
+ errorMsg = err;
427
+ } else if ((err as any).message && (err as any).message.length !== 0) {
428
+ errorMsg = (err as any).message;
429
+ }
430
+ showToast(errorMsg, 3000);
431
+ }
327
432
  };
328
433
  }
329
434
 
435
+ // cURL button
330
436
  const curlBtn = document.createElement("button");
331
- popupInputButtons.push(curlBtn);
332
- curlBtn.innerText = "cURL";
333
- curlBtn.className = "yasqe_btn yasqe_btn-sm curl";
334
- popup.appendChild(curlBtn);
335
- curlBtn.onclick = () => {
336
- popupInputButtons.forEach((button) => (button.disabled = true));
337
- input.value = this.getAsCurlString();
338
- input.focus();
339
- popup?.appendChild(curlBtn);
437
+ curlBtn.innerText = "Copy cURL";
438
+ curlBtn.className = "yasqe_btn yasqe_btn-sm yasqe_shareBtn";
439
+ buttonContainer.appendChild(curlBtn);
440
+ curlBtn.onclick = async () => {
441
+ const curlString = this.getAsCurlString();
442
+ const hasAuth = this.hasAuthenticationCredentials();
443
+ await copyToClipboard(curlString, "cURL command", hasAuth);
444
+ };
445
+
446
+ // PowerShell button
447
+ const psBtn = document.createElement("button");
448
+ psBtn.innerText = "Copy PowerShell";
449
+ psBtn.className = "yasqe_btn yasqe_btn-sm yasqe_shareBtn";
450
+ buttonContainer.appendChild(psBtn);
451
+ psBtn.onclick = async () => {
452
+ const psString = this.getAsPowerShellString();
453
+ const hasAuth = this.hasAuthenticationCredentials();
454
+ await copyToClipboard(psString, "PowerShell command", hasAuth);
455
+ };
456
+
457
+ // wget button
458
+ const wgetBtn = document.createElement("button");
459
+ wgetBtn.innerText = "Copy wget";
460
+ wgetBtn.className = "yasqe_btn yasqe_btn-sm yasqe_shareBtn";
461
+ buttonContainer.appendChild(wgetBtn);
462
+ wgetBtn.onclick = async () => {
463
+ const wgetString = this.getAsWgetString();
464
+ const hasAuth = this.hasAuthenticationCredentials();
465
+ await copyToClipboard(wgetString, "wget command", hasAuth);
466
+ };
467
+
468
+ // Position popup after layout is complete
469
+ const positionPopup = () => {
470
+ if (!popup) return;
471
+ const svgPos = svgShare.getBoundingClientRect();
472
+ popup.style.top = svgShare.offsetTop + svgPos.height + "px";
473
+ popup.style.left = svgShare.offsetLeft + svgShare.clientWidth - popup.clientWidth + "px";
340
474
  };
341
475
 
342
- const svgPos = svgShare.getBoundingClientRect();
343
- popup.style.top = svgShare.offsetTop + svgPos.height + "px";
344
- popup.style.left = svgShare.offsetLeft + svgShare.clientWidth - popup.clientWidth + "px";
345
- input.focus();
476
+ if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
477
+ window.requestAnimationFrame(positionPopup);
478
+ } else {
479
+ // Fallback for environments without requestAnimationFrame
480
+ setTimeout(positionPopup, 0);
481
+ }
346
482
  };
347
483
  }
348
484
  /**
@@ -1398,6 +1534,19 @@ export class Yasqe extends CodeMirror {
1398
1534
  return Sparql.getAsCurlString(this, config);
1399
1535
  }
1400
1536
 
1537
+ public getAsPowerShellString(config?: Sparql.YasqeAjaxConfig): string {
1538
+ return Sparql.getAsPowerShellString(this, config);
1539
+ }
1540
+
1541
+ public getAsWgetString(config?: Sparql.YasqeAjaxConfig): string {
1542
+ return Sparql.getAsWgetString(this, config);
1543
+ }
1544
+
1545
+ public hasAuthenticationCredentials(config?: Sparql.YasqeAjaxConfig): boolean {
1546
+ const ajaxConfig = Sparql.getAjaxConfig(this, config);
1547
+ return ajaxConfig ? Sparql.hasAuthenticationCredentials(ajaxConfig) : false;
1548
+ }
1549
+
1401
1550
  public abortQuery() {
1402
1551
  if (this.req) {
1403
1552
  if (this.abortController) {