From 63c9bb45c982cf5ffe28b6e880e8c2fb3fb0e536 Mon Sep 17 00:00:00 2001 From: Ivo Velitchkov Date: Wed, 26 Aug 2026 17:48:35 +0200 Subject: [PATCH 1/3] Extend SPARQL example connection dialog - Optional HTTP Basic authentication via a custom queryFunction; credentials are kept in tab-scoped session storage, keyed to their endpoint, and never enter the URL hash. - Optional restriction of all queries to one or more named graphs via SPARQL Protocol default-graph-uri parameters. - Recently used connections (endpoint, graphs, username) persisted in local storage, refilling the form on click. - Write the URL hash once per save and parse it from location.href, whose fragment is not percent-decoded by Firefox unlike location.hash. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 + examples/resources/common.tsx | 26 +- examples/resources/sparqlConnection.tsx | 357 ++++++++++++++++++++++-- examples/sparql.tsx | 17 +- 4 files changed, 366 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5510435d..6a136a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to the Reactodia will be documented in this document. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +#### ๐Ÿ’… Polish +- Extend SPARQL example connection dialog: + * optional HTTP Basic authentication (credentials are kept in tab-scoped session storage, not in the URL); + * optional restriction of all queries to one or more named graphs via SPARQL Protocol `default-graph-uri` parameters; + * recently used connections (endpoint, graphs, username โ€“ no passwords) persisted in local storage for quick re-connection. ## [0.35.2] - 2026-08-08 #### ๐Ÿ› Fixed diff --git a/examples/resources/common.tsx b/examples/resources/common.tsx index 73593c40..d651ccd3 100644 --- a/examples/resources/common.tsx +++ b/examples/resources/common.tsx @@ -113,10 +113,14 @@ export function ExampleToolbarMenu() { } export function getHashQuery(): URLSearchParams | undefined { - const hash = window.location.hash; - if (hash.length > 1 && hash.includes('=')) { + // Parse the raw fragment from `href`: the `location.hash` getter returns it + // percent-decoded in Firefox, which corrupts values with encoded & or = + const href = window.location.href; + const hashIndex = href.indexOf('#'); + const hash = hashIndex >= 0 ? href.substring(hashIndex + 1) : ''; + if (hash.length > 0 && hash.includes('=')) { try { - const hashQuery = new URLSearchParams(hash.substring(1)); + const hashQuery = new URLSearchParams(hash); return hashQuery; } catch (e) { /* ignore */ @@ -125,16 +129,22 @@ export function getHashQuery(): URLSearchParams | undefined { return undefined; } -export function setHashQueryParam(paramName: string, paramValue: string | null): void { +export function setHashQueryParams(params: { readonly [name: string]: string | null }): void { const hashQuery = getHashQuery() ?? new URLSearchParams(); - if (paramValue) { - hashQuery.set(paramName, paramValue); - } else { - hashQuery.delete(paramName); + for (const [paramName, paramValue] of Object.entries(params)) { + if (paramValue) { + hashQuery.set(paramName, paramValue); + } else { + hashQuery.delete(paramName); + } } window.location.hash = hashQuery.toString(); } +export function setHashQueryParam(paramName: string, paramValue: string | null): void { + setHashQueryParams({[paramName]: paramValue}); +} + export function tryLoadLayoutFromLocalStorage(): Reactodia.SerializedDiagram | undefined { let layoutKey: string | null = null; diff --git a/examples/resources/sparqlConnection.tsx b/examples/resources/sparqlConnection.tsx index 2b1a46a3..bb2fbac8 100644 --- a/examples/resources/sparqlConnection.tsx +++ b/examples/resources/sparqlConnection.tsx @@ -1,8 +1,199 @@ import * as React from 'react'; import * as Reactodia from '../../src/workspace'; +import { getHashQuery, setHashQueryParams } from './common'; + export interface SparqlConnectionSettings { readonly endpointUrl: string; + /** + * Named graph IRIs to restrict all queries to, applied via the SPARQL 1.1 Protocol + * `default-graph-uri` parameters: the queried default graph is the merge of these + * graphs (requires the endpoint to support dataset specification via protocol + * parameters). + * + * If the schema (class and property declarations) is stored separately + * from the instance data, list both graphs, otherwise there will be + * no link types to display. + */ + readonly defaultGraphIris?: ReadonlyArray; + /** + * Username for HTTP Basic authentication on the endpoint. + */ + readonly username?: string; + /** + * Password for HTTP Basic authentication on the endpoint. + */ + readonly password?: string; +} + +const CREDENTIALS_SESSION_KEY = 'reactodia-sparql-credentials'; +const RECENT_CONNECTIONS_KEY = 'reactodia-sparql-recent-connections'; +const RECENT_CONNECTIONS_LIMIT = 8; + +/** + * Connection settings without the password, as remembered in the recent + * connections list ({@link localStorage}, shared between browser tabs). + */ +interface RecentConnection { + readonly endpointUrl: string; + readonly defaultGraphIris?: ReadonlyArray; + readonly username?: string; +} + +function loadRecentConnections(): RecentConnection[] { + try { + const stored = localStorage.getItem(RECENT_CONNECTIONS_KEY); + const parsed = stored ? JSON.parse(stored) as RecentConnection[] : []; + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + return []; + } +} + +function storeRecentConnections(connections: ReadonlyArray): void { + try { + localStorage.setItem(RECENT_CONNECTIONS_KEY, JSON.stringify(connections)); + } catch (e) { + /* ignore */ + } +} + +function rememberRecentConnection(settings: SparqlConnectionSettings): void { + const entry: RecentConnection = { + endpointUrl: settings.endpointUrl, + defaultGraphIris: settings.defaultGraphIris, + username: settings.username, + }; + const entryKey = JSON.stringify(entry); + const connections = [ + entry, + ...loadRecentConnections().filter(other => JSON.stringify(other) !== entryKey), + ].slice(0, RECENT_CONNECTIONS_LIMIT); + storeRecentConnections(connections); +} + +function formatRecentConnection(recent: RecentConnection): string { + const host = URL.canParse(recent.endpointUrl) + ? new URL(recent.endpointUrl).host : recent.endpointUrl; + const graphCount = recent.defaultGraphIris?.length ?? 0; + return [ + host, + graphCount > 0 ? `${graphCount} graph${graphCount === 1 ? '' : 's'}` : undefined, + recent.username, + ].filter(Boolean).join(' ยท '); +} + +/** + * Restores connection settings persisted by {@link saveConnectionSettings}: + * the endpoint URL and graph IRI from the URL hash, the credentials + * from the tab-scoped session storage. + */ +export function loadConnectionSettings(): SparqlConnectionSettings | undefined { + const params = getHashQuery(); + const endpointUrl = params?.get('sparql-endpoint'); + if (!endpointUrl) { + return undefined; + } + const defaultGraphIris = parseGraphIris(params?.get('sparql-graph') ?? ''); + let username: string | undefined; + let password: string | undefined; + try { + const storedCredentials = sessionStorage.getItem(CREDENTIALS_SESSION_KEY); + if (storedCredentials) { + const credentials = JSON.parse(storedCredentials) as { + endpointUrl?: string; + username?: string; + password?: string; + }; + // The hash is editable and shareable, so attach the stored + // credentials only to the endpoint they were entered for, + // never to whatever endpoint a pasted link happens to name + if (credentials.endpointUrl === endpointUrl) { + username = credentials.username; + password = credentials.password; + } + } + } catch (e) { + /* ignore */ + } + return {endpointUrl, defaultGraphIris, username, password}; +} + +export function saveConnectionSettings(settings: SparqlConnectionSettings): void { + setHashQueryParams({ + 'sparql-endpoint': settings.endpointUrl, + 'sparql-graph': settings.defaultGraphIris?.join(' ') ?? null, + }); + rememberRecentConnection(settings); + // Credentials are kept out of the URL hash to avoid leaking them via + // browser history or copied links; session storage is tab-scoped and + // cleared when the tab is closed. + try { + if (settings.username) { + sessionStorage.setItem(CREDENTIALS_SESSION_KEY, JSON.stringify({ + endpointUrl: settings.endpointUrl, + username: settings.username, + password: settings.password, + })); + } else { + sessionStorage.removeItem(CREDENTIALS_SESSION_KEY); + } + } catch (e) { + /* ignore */ + } +} + +export function parseGraphIris(text: string): ReadonlyArray | undefined { + // Whitespace only: comma is a legal IRI character (RFC 3987 sub-delims) + const iris = text.split(/\s+/).filter(iri => iri.length > 0); + return iris.length > 0 ? iris : undefined; +} + +/** + * Computes {@link Reactodia.SparqlDataProviderOptions} part for the connection settings: + * the endpoint URL with `default-graph-uri` parameters if graph IRIs are set, + * and a query function sending the `Authorization` header if credentials are set. + */ +export function createConnectionOptions( + settings: SparqlConnectionSettings +): Pick { + const {endpointUrl, defaultGraphIris, username, password} = settings; + + let effectiveEndpointUrl = endpointUrl; + for (const graphIri of defaultGraphIris ?? []) { + const separator = effectiveEndpointUrl.includes('?') ? '&' : '?'; + effectiveEndpointUrl += + `${separator}default-graph-uri=${encodeURIComponent(graphIri)}`; + } + + let queryFunction: Reactodia.SparqlQueryFunction | undefined; + if (username) { + const authorization = `Basic ${encodeBase64(`${username}:${password ?? ''}`)}`; + queryFunction = params => fetch(params.url, { + method: params.method, + body: params.body, + credentials: 'same-origin', + mode: 'cors', + cache: 'default', + headers: { + ...params.headers, + 'Authorization': authorization, + }, + signal: params.signal, + }); + } + + return {endpointUrl: effectiveEndpointUrl, queryFunction}; +} + +function encodeBase64(text: string): string { + // btoa() alone throws on characters outside the Latin-1 range + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); } export function SparqlConnectionAction(props: { @@ -20,6 +211,9 @@ export function SparqlConnectionAction(props: { showConnectionDialog(settings, applySettings, context)}> SPARQL endpoint: {endpointUrl?.host ?? settings.endpointUrl} + {settings.defaultGraphIris?.length + ? ` (${settings.defaultGraphIris.length} graph${settings.defaultGraphIris.length === 1 ? '' : 's'})` + : null} ); } @@ -33,8 +227,8 @@ export function showConnectionDialog( overlay.showDialog({ style: { caption: 'SPARQL connection settings', - defaultSize: {width: 400, height: 250}, - resizableBy: 'x', + defaultSize: {width: 400, height: 600}, + resizableBy: 'all', closable: Boolean(initialSettings), }, content: ( @@ -54,14 +248,57 @@ export function SparqlConnectionForm(props: { onSubmit: (settings: SparqlConnectionSettings) => void; }) { const {initialSettings, onSubmit} = props; - const [settings, setSettings] = React.useState( - initialSettings ?? {endpointUrl: ''} - ); - const isValidEndpoint = settings.endpointUrl.length === 0 || URL.canParse(settings.endpointUrl); - const canSubmit = settings.endpointUrl.length > 0 && isValidEndpoint; + const [draft, setDraft] = React.useState(() => ({ + endpointUrl: initialSettings?.endpointUrl ?? '', + graphText: initialSettings?.defaultGraphIris?.join(' ') ?? '', + username: initialSettings?.username ?? '', + password: initialSettings?.password ?? '', + })); + const [recentConnections, setRecentConnections] = React.useState(loadRecentConnections); + const passwordInputRef = React.useRef(null); + const [focusPasswordToken, setFocusPasswordToken] = React.useState(0); + React.useEffect(() => { + if (focusPasswordToken > 0) { + passwordInputRef.current?.focus(); + } + }, [focusPasswordToken]); + const applyRecentConnection = (recent: RecentConnection) => { + setDraft({ + endpointUrl: recent.endpointUrl, + graphText: recent.defaultGraphIris?.join(' ') ?? '', + username: recent.username ?? '', + password: '', + }); + // Passwords are deliberately not remembered, so point the user + // at the field that still needs a value + if (recent.username) { + setFocusPasswordToken(token => token + 1); + } + }; + const forgetRecentConnection = (index: number) => { + const remaining = recentConnections.filter((_, i) => i !== index); + setRecentConnections(remaining); + storeRecentConnections(remaining); + }; + const isValidEndpoint = draft.endpointUrl.length === 0 || URL.canParse(draft.endpointUrl); + const invalidGraph = (parseGraphIris(draft.graphText) ?? []) + .find(iri => !URL.canParse(iri)); + const canSubmit = draft.endpointUrl.length > 0 && isValidEndpoint && !invalidGraph; + const submitSettings = () => onSubmit({ + endpointUrl: draft.endpointUrl, + defaultGraphIris: parseGraphIris(draft.graphText), + username: draft.username || undefined, + password: draft.password || undefined, + }); + const submitOnEnter = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && canSubmit) { + submitSettings(); + } + }; return (
-
+
{ const endpointUrl = e.currentTarget.value; - setSettings(previous => ({...previous, endpointUrl})); - }} - onKeyDown={e => { - if (e.key === 'Enter' && canSubmit) { - onSubmit(settings); - } + setDraft(previous => ({...previous, endpointUrl})); }} + onKeyDown={submitOnEnter} /> {isValidEndpoint ? null : (
@@ -87,15 +320,103 @@ export function SparqlConnectionForm(props: { )}
- A public SPARQL endpoints will work if only if its configured - to allow cross-origin GET queries (CORS headers). + + { + const graphText = e.currentTarget.value; + setDraft(previous => ({...previous, graphText})); + }} + onKeyDown={submitOnEnter} + /> + {invalidGraph ? ( +
+ Invalid graph IRI: {invalidGraph} +
+ ) : null} +
+
+ + { + const username = e.currentTarget.value; + setDraft(previous => ({...previous, username})); + }} + onKeyDown={submitOnEnter} + /> +
+
+ + { + const password = e.currentTarget.value; + setDraft(previous => ({...previous, password})); + }} + onKeyDown={submitOnEnter} + /> +
+ {recentConnections.length === 0 ? null : ( +
+ + {recentConnections.map((recent, index) => ( +
+ + +
+ ))} +
+ )} +
+ A public SPARQL endpoint will work only if it is configured + to allow cross-origin queries (CORS headers, including + the Authorization header when credentials are used). + Credentials are sent with each request and kept only + for the current browser tab. +
+
+ If the schema is stored separately from the data, list both + graphs, otherwise there will be no link types to display.
diff --git a/examples/sparql.tsx b/examples/sparql.tsx index d7572637..dc4f487c 100644 --- a/examples/sparql.tsx +++ b/examples/sparql.tsx @@ -5,11 +5,10 @@ import { ExampleToolbarMenu, mountOnLoad, tryLoadLayoutFromLocalStorage, - getHashQuery, - setHashQueryParam, } from './resources/common'; import { SparqlConnectionSettings, SparqlConnectionAction, showConnectionDialog, + loadConnectionSettings, saveConnectionSettings, createConnectionOptions, } from './resources/sparqlConnection'; const Layouts = Reactodia.defineLayoutWorker(() => new Worker( @@ -23,17 +22,9 @@ function SparqlExample() { defaultLayout, })); - const [connectionSettings, setConnectionSettings] = React.useState( - (): SparqlConnectionSettings | undefined => { - const params = getHashQuery(); - const endpointUrl = params?.get('sparql-endpoint'); - return endpointUrl ? { - endpointUrl, - } : undefined; - } - ); + const [connectionSettings, setConnectionSettings] = React.useState(loadConnectionSettings); const applyConnectionSettings = (settings: SparqlConnectionSettings) => { - setHashQueryParam('sparql-endpoint', settings.endpointUrl); + saveConnectionSettings(settings); setConnectionSettings(settings); }; @@ -43,7 +34,7 @@ function SparqlExample() { if (connectionSettings) { const diagram = tryLoadLayoutFromLocalStorage(); const dataProvider = new Reactodia.SparqlDataProvider({ - endpointUrl: connectionSettings.endpointUrl, + ...createConnectionOptions(connectionSettings), imagePropertyUris: ['http://xmlns.com/foaf/0.1/img'], }, Reactodia.OwlStatsSettings); From 81353d7596909bce1ac40bd6c7db38ffd41cbe44 Mon Sep 17 00:00:00 2001 From: Ivo Velitchkov Date: Wed, 26 Aug 2026 18:09:58 +0200 Subject: [PATCH 2/3] Support naming saved connections in the SPARQL example dialog A named connection is pinned: it is never evicted from the list, while unnamed ones rotate through the most recent few. Activating an entry connects directly when no credentials are needed, otherwise it fills the form and focuses the password field. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- examples/resources/sparqlConnection.tsx | 82 +++++++++++++++++++++---- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a136a6d..d1546275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Extend SPARQL example connection dialog: * optional HTTP Basic authentication (credentials are kept in tab-scoped session storage, not in the URL); * optional restriction of all queries to one or more named graphs via SPARQL Protocol `default-graph-uri` parameters; - * recently used connections (endpoint, graphs, username โ€“ no passwords) persisted in local storage for quick re-connection. + * saved and recent connections (endpoint, graphs, username โ€“ no passwords) persisted in local storage: an unnamed connection rotates through the most recent few, a named one is pinned permanently; activating one connects directly, or pre-fills the form when a password needs re-entering. ## [0.35.2] - 2026-08-08 #### ๐Ÿ› Fixed diff --git a/examples/resources/sparqlConnection.tsx b/examples/resources/sparqlConnection.tsx index bb2fbac8..e61beb43 100644 --- a/examples/resources/sparqlConnection.tsx +++ b/examples/resources/sparqlConnection.tsx @@ -33,11 +33,24 @@ const RECENT_CONNECTIONS_LIMIT = 8; /** * Connection settings without the password, as remembered in the recent * connections list ({@link localStorage}, shared between browser tabs). + * + * An entry with a user-assigned {@link label} is pinned: it is never evicted + * from the list, so named configurations accumulate without limit while + * unnamed ones rotate through the most recent few. */ interface RecentConnection { readonly endpointUrl: string; readonly defaultGraphIris?: ReadonlyArray; readonly username?: string; + readonly label?: string; +} + +function connectionKey(connection: RecentConnection): string { + return JSON.stringify([ + connection.endpointUrl, + connection.defaultGraphIris ?? [], + connection.username ?? '', + ]); } function loadRecentConnections(): RecentConnection[] { @@ -64,15 +77,25 @@ function rememberRecentConnection(settings: SparqlConnectionSettings): void { defaultGraphIris: settings.defaultGraphIris, username: settings.username, }; - const entryKey = JSON.stringify(entry); + const entryKey = connectionKey(entry); + const existing = loadRecentConnections(); + const previous = existing.find(other => connectionKey(other) === entryKey); + // The limit applies to unnamed entries only; named ones are pinned + let unnamedCount = 0; const connections = [ - entry, - ...loadRecentConnections().filter(other => JSON.stringify(other) !== entryKey), - ].slice(0, RECENT_CONNECTIONS_LIMIT); + {...entry, label: previous?.label}, + ...existing.filter(other => connectionKey(other) !== entryKey), + ].filter(connection => connection.label + ? true + : ++unnamedCount <= RECENT_CONNECTIONS_LIMIT + ); storeRecentConnections(connections); } function formatRecentConnection(recent: RecentConnection): string { + if (recent.label) { + return recent.label; + } const host = URL.canParse(recent.endpointUrl) ? new URL(recent.endpointUrl).host : recent.endpointUrl; const graphCount = recent.defaultGraphIris?.length ?? 0; @@ -263,18 +286,38 @@ export function SparqlConnectionForm(props: { } }, [focusPasswordToken]); const applyRecentConnection = (recent: RecentConnection) => { - setDraft({ - endpointUrl: recent.endpointUrl, - graphText: recent.defaultGraphIris?.join(' ') ?? '', - username: recent.username ?? '', - password: '', - }); - // Passwords are deliberately not remembered, so point the user - // at the field that still needs a value if (recent.username) { + // Passwords are deliberately not remembered: fill the form and + // point the user at the field that still needs a value + setDraft({ + endpointUrl: recent.endpointUrl, + graphText: recent.defaultGraphIris?.join(' ') ?? '', + username: recent.username, + password: '', + }); setFocusPasswordToken(token => token + 1); + } else { + onSubmit({ + endpointUrl: recent.endpointUrl, + defaultGraphIris: recent.defaultGraphIris, + }); } }; + const nameRecentConnection = (index: number) => { + const connection = recentConnections[index]; + const label = window.prompt( + 'Name this connection (leave empty to unname it):', + connection.label ?? '' + ); + if (label === null) { + return; + } + const renamed = recentConnections.map((other, i) => i === index + ? {...other, label: label.trim() || undefined} + : other); + setRecentConnections(renamed); + storeRecentConnections(renamed); + }; const forgetRecentConnection = (index: number) => { const remaining = recentConnections.filter((_, i) => i !== index); setRecentConnections(remaining); @@ -371,7 +414,7 @@ export function SparqlConnectionForm(props: {
{recentConnections.length === 0 ? null : (
- + {recentConnections.map((recent, index) => (
@@ -384,12 +427,23 @@ export function SparqlConnectionForm(props: { whiteSpace: 'nowrap', }} title={[ + recent.username + ? 'Fill the connection form (the password will need to be re-entered)' + : 'Connect', recent.endpointUrl, ...(recent.defaultGraphIris ?? []), + ...(recent.username ? [`user: ${recent.username}`] : []), ].join('\n')} onClick={() => applyRecentConnection(recent)}> {formatRecentConnection(recent)} +
From 145f03ff4f14df18794c69c47b513ac564c0cace Mon Sep 17 00:00:00 2001 From: Ivo Velitchkov Date: Wed, 26 Aug 2026 18:22:15 +0200 Subject: [PATCH 3/3] Remember connections activated from the URL hash A connection restored from a bookmarked or shared URL now lands in the saved connections list the same as one submitted through the dialog, which previously was the only way an entry was recorded. Co-Authored-By: Claude Fable 5 --- examples/resources/sparqlConnection.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/resources/sparqlConnection.tsx b/examples/resources/sparqlConnection.tsx index e61beb43..a436cd3c 100644 --- a/examples/resources/sparqlConnection.tsx +++ b/examples/resources/sparqlConnection.tsx @@ -139,7 +139,11 @@ export function loadConnectionSettings(): SparqlConnectionSettings | undefined { } catch (e) { /* ignore */ } - return {endpointUrl, defaultGraphIris, username, password}; + const settings: SparqlConnectionSettings = {endpointUrl, defaultGraphIris, username, password}; + // A connection activated from a bookmarked or restored URL should appear + // in the saved list the same as one submitted through the dialog + rememberRecentConnection(settings); + return settings; } export function saveConnectionSettings(settings: SparqlConnectionSettings): void {