From 3551956a89a98877214892f0142369bb358d6795 Mon Sep 17 00:00:00 2001 From: Ivo Velitchkov Date: Wed, 7 Oct 2026 23:19:13 +0300 Subject: [PATCH 1/5] 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 --- examples/resources/common.tsx | 26 +- examples/resources/sparqlConnection.tsx | 357 ++++++++++++++++++++++-- examples/sparql.tsx | 17 +- 3 files changed, 361 insertions(+), 39 deletions(-) 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 b013dd3939e5ce0abefaae862fddbafeedd7e135 Mon Sep 17 00:00:00 2001 From: Ivo Velitchkov Date: Wed, 7 Oct 2026 23:19:42 +0300 Subject: [PATCH 2/5] 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 --- examples/resources/sparqlConnection.tsx | 82 +++++++++++++++++++++---- 1 file changed, 69 insertions(+), 13 deletions(-) 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 8334370b82b2c24b75ba3d7e46f44568824f6f71 Mon Sep 17 00:00:00 2001 From: Ivo Velitchkov Date: Wed, 7 Oct 2026 23:19:42 +0300 Subject: [PATCH 3/5] 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 { From 8fa7430a481bf1627998ed9bb3d035864f022623 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Thu, 8 Oct 2026 00:06:35 +0300 Subject: [PATCH 4/5] Re-structure SPARQL example components --- examples/sparql.html | 2 +- examples/sparql/recentConnections.tsx | 160 ++++++++++++++ examples/sparql/sparql.module.css | 12 + examples/{ => sparql}/sparql.tsx | 6 +- .../sparqlConnection.tsx | 205 +++--------------- 5 files changed, 212 insertions(+), 173 deletions(-) create mode 100644 examples/sparql/recentConnections.tsx create mode 100644 examples/sparql/sparql.module.css rename examples/{ => sparql}/sparql.tsx (96%) rename examples/{resources => sparql}/sparqlConnection.tsx (63%) diff --git a/examples/sparql.html b/examples/sparql.html index fa334b84..767a357e 100644 --- a/examples/sparql.html +++ b/examples/sparql.html @@ -9,6 +9,6 @@ - + diff --git a/examples/sparql/recentConnections.tsx b/examples/sparql/recentConnections.tsx new file mode 100644 index 00000000..efb11eeb --- /dev/null +++ b/examples/sparql/recentConnections.tsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import * as Reactodia from '../../src/workspace'; +import cx from 'clsx'; + +import styles from './sparql.module.css'; + +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; +} + +export function RecentConnections(props: { + current?: RecentConnection; + onApply: (connection: RecentConnection) => void; +}) { + const {current, onApply} = props; + const [storedConnections, setStoredConnections] = React.useState(loadRecentConnections); + + let recentConnections = storedConnections; + if (current && !recentConnections.find(other => equalRecentConnections(other, current))) { + recentConnections = [current, ...recentConnections]; + } + + if (recentConnections.length === 0) { + return null; + } + + 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); + setStoredConnections(renamed); + storeRecentConnections(renamed); + }; + + const forgetRecentConnection = (index: number) => { + const remaining = recentConnections.filter((_, i) => i !== index); + setStoredConnections(remaining); + storeRecentConnections(remaining); + }; + + return ( +
+ + {recentConnections.map((recent, index) => ( +
+ + + +
+ ))} +
+ ); +} + +function equalRecentConnections(a: RecentConnection, b: RecentConnection) { + return ( + a.endpointUrl === b.endpointUrl && + Reactodia.shallowArrayEqual(a.defaultGraphIris ?? [], b.defaultGraphIris ?? []) && + (a.username ?? '') === (b.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 */ + } +} + +export function rememberRecentConnection(connection: RecentConnection): void { + const existing = loadRecentConnections(); + const previous = existing.find(other => equalRecentConnections(connection, other)); + // The limit applies to unnamed entries only; named ones are pinned + let unnamedCount = 0; + const connections: RecentConnection[] = [{...connection, label: previous?.label}]; + for (const other of existing) { + if (equalRecentConnections(other, connection)) { + continue; + } else if (other.label || unnamedCount < RECENT_CONNECTIONS_LIMIT) { + connections.push(other); + if (!other.label) { + unnamedCount++; + } + } + } + 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(' · '); +} diff --git a/examples/sparql/sparql.module.css b/examples/sparql/sparql.module.css new file mode 100644 index 00000000..6822bc4e --- /dev/null +++ b/examples/sparql/sparql.module.css @@ -0,0 +1,12 @@ +.recentConnection { + display: flex; + gap: 4px; + margin-bottom: 4px; +} + +.recentConnectionLabel { + flex: auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/examples/sparql.tsx b/examples/sparql/sparql.tsx similarity index 96% rename from examples/sparql.tsx rename to examples/sparql/sparql.tsx index dc4f487c..cdb12666 100644 --- a/examples/sparql.tsx +++ b/examples/sparql/sparql.tsx @@ -1,15 +1,15 @@ import * as React from 'react'; -import * as Reactodia from '../src/workspace'; +import * as Reactodia from '../../src/workspace'; import { ExampleToolbarMenu, mountOnLoad, tryLoadLayoutFromLocalStorage, -} from './resources/common'; +} from '../resources/common'; import { SparqlConnectionSettings, SparqlConnectionAction, showConnectionDialog, loadConnectionSettings, saveConnectionSettings, createConnectionOptions, -} from './resources/sparqlConnection'; +} from './sparqlConnection'; const Layouts = Reactodia.defineLayoutWorker(() => new Worker( new URL('../src/layout.worker.ts', import.meta.url), diff --git a/examples/resources/sparqlConnection.tsx b/examples/sparql/sparqlConnection.tsx similarity index 63% rename from examples/resources/sparqlConnection.tsx rename to examples/sparql/sparqlConnection.tsx index a436cd3c..f8087881 100644 --- a/examples/resources/sparqlConnection.tsx +++ b/examples/sparql/sparqlConnection.tsx @@ -1,9 +1,14 @@ import * as React from 'react'; import * as Reactodia from '../../src/workspace'; -import { getHashQuery, setHashQueryParams } from './common'; +import { getHashQuery, setHashQueryParams } from '../resources/common'; + +import { RecentConnections, rememberRecentConnection } from './recentConnections'; export interface SparqlConnectionSettings { + /** + * SPARQL endpoint URL. + */ readonly endpointUrl: string; /** * Named graph IRIs to restrict all queries to, applied via the SPARQL 1.1 Protocol @@ -27,84 +32,6 @@ export interface SparqlConnectionSettings { } 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}: @@ -139,11 +66,12 @@ export function loadConnectionSettings(): SparqlConnectionSettings | undefined { } 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; + return { + endpointUrl, + defaultGraphIris, + username, + password, + }; } export function saveConnectionSettings(settings: SparqlConnectionSettings): void { @@ -170,7 +98,7 @@ export function saveConnectionSettings(settings: SparqlConnectionSettings): void } } -export function parseGraphIris(text: string): ReadonlyArray | undefined { +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; @@ -270,7 +198,7 @@ export function showConnectionDialog( }); } -export function SparqlConnectionForm(props: { +function SparqlConnectionForm(props: { initialSettings: SparqlConnectionSettings | undefined; onSubmit: (settings: SparqlConnectionSettings) => void; }) { @@ -281,7 +209,6 @@ export function SparqlConnectionForm(props: { 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(() => { @@ -289,44 +216,6 @@ export function SparqlConnectionForm(props: { 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)); @@ -344,8 +233,7 @@ export function SparqlConnectionForm(props: { }; return (
-
+
- {recentConnections.length === 0 ? null : ( -
- - {recentConnections.map((recent, index) => ( -
- - - -
- ))} -
- )} + { + 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, + }); + } + }} + />
A public SPARQL endpoint will work only if it is configured to allow cross-origin queries (CORS headers, including From 3c7714611b34b8733d7d9f661bf3a0b1d8589389 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Thu, 8 Oct 2026 00:09:43 +0300 Subject: [PATCH 5/5] Fix layout worker URL in SPARQL example --- examples/sparql/sparql.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sparql/sparql.tsx b/examples/sparql/sparql.tsx index cdb12666..32c02274 100644 --- a/examples/sparql/sparql.tsx +++ b/examples/sparql/sparql.tsx @@ -12,7 +12,7 @@ import { } from './sparqlConnection'; const Layouts = Reactodia.defineLayoutWorker(() => new Worker( - new URL('../src/layout.worker.ts', import.meta.url), + new URL('../../src/layout.worker.ts', import.meta.url), {type: 'module'} ));