diff --git a/CHANGELOG.md b/CHANGELOG.md index 5510435d..d1546275 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; + * 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/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..a436cd3c 100644 --- a/examples/resources/sparqlConnection.tsx +++ b/examples/resources/sparqlConnection.tsx @@ -1,8 +1,226 @@ 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). + * + * 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[] { + 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 = 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, 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; + 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 */ + } + 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 { + 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 +238,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 +254,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 +275,77 @@ 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) => { + 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); + 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 +367,116 @@ 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. + Naming a connection (โœŽ) keeps it in the list permanently; + unnamed ones rotate through the most recent few.
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);