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 deleted file mode 100644 index 2b1a46a3..00000000 --- a/examples/resources/sparqlConnection.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import * as React from 'react'; -import * as Reactodia from '../../src/workspace'; - -export interface SparqlConnectionSettings { - readonly endpointUrl: string; -} - -export function SparqlConnectionAction(props: { - settings: SparqlConnectionSettings | undefined; - applySettings: (settings: SparqlConnectionSettings) => void; -}) { - const {settings, applySettings} = props; - if (!settings) { - return null; - } - const context = Reactodia.useWorkspace(); - const endpointUrl = URL.canParse(settings.endpointUrl) - ? new URL(settings.endpointUrl) : undefined; - return ( - showConnectionDialog(settings, applySettings, context)}> - SPARQL endpoint: {endpointUrl?.host ?? settings.endpointUrl} - - ); -} - -export function showConnectionDialog( - initialSettings: SparqlConnectionSettings | undefined, - applySettings: (settings: SparqlConnectionSettings) => void, - context: Reactodia.WorkspaceContext -): void { - const {overlay} = context; - overlay.showDialog({ - style: { - caption: 'SPARQL connection settings', - defaultSize: {width: 400, height: 250}, - resizableBy: 'x', - closable: Boolean(initialSettings), - }, - content: ( - { - overlay.hideDialog(); - applySettings(settings); - }} - /> - ), - }); -} - -export function SparqlConnectionForm(props: { - initialSettings: SparqlConnectionSettings | undefined; - 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; - return ( -
-
-
- - { - const endpointUrl = e.currentTarget.value; - setSettings(previous => ({...previous, endpointUrl})); - }} - onKeyDown={e => { - if (e.key === 'Enter' && canSubmit) { - onSubmit(settings); - } - }} - /> - {isValidEndpoint ? null : ( -
- Invalid URL -
- )} -
-
- A public SPARQL endpoints will work if only if its configured - to allow cross-origin GET queries (CORS headers). -
-
-
- -
-
- ); -} 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 81% rename from examples/sparql.tsx rename to examples/sparql/sparql.tsx index d7572637..32c02274 100644 --- a/examples/sparql.tsx +++ b/examples/sparql/sparql.tsx @@ -1,19 +1,18 @@ import * as React from 'react'; -import * as Reactodia from '../src/workspace'; +import * as Reactodia from '../../src/workspace'; import { ExampleToolbarMenu, mountOnLoad, tryLoadLayoutFromLocalStorage, - getHashQuery, - setHashQueryParam, -} from './resources/common'; +} from '../resources/common'; import { SparqlConnectionSettings, SparqlConnectionAction, showConnectionDialog, -} from './resources/sparqlConnection'; + loadConnectionSettings, saveConnectionSettings, createConnectionOptions, +} 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'} )); @@ -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); diff --git a/examples/sparql/sparqlConnection.tsx b/examples/sparql/sparqlConnection.tsx new file mode 100644 index 00000000..f8087881 --- /dev/null +++ b/examples/sparql/sparqlConnection.tsx @@ -0,0 +1,352 @@ +import * as React from 'react'; +import * as Reactodia from '../../src/workspace'; + +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 + * `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'; + +/** + * 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 */ + } +} + +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: { + settings: SparqlConnectionSettings | undefined; + applySettings: (settings: SparqlConnectionSettings) => void; +}) { + const {settings, applySettings} = props; + if (!settings) { + return null; + } + const context = Reactodia.useWorkspace(); + const endpointUrl = URL.canParse(settings.endpointUrl) + ? new URL(settings.endpointUrl) : undefined; + return ( + showConnectionDialog(settings, applySettings, context)}> + SPARQL endpoint: {endpointUrl?.host ?? settings.endpointUrl} + {settings.defaultGraphIris?.length + ? ` (${settings.defaultGraphIris.length} graph${settings.defaultGraphIris.length === 1 ? '' : 's'})` + : null} + + ); +} + +export function showConnectionDialog( + initialSettings: SparqlConnectionSettings | undefined, + applySettings: (settings: SparqlConnectionSettings) => void, + context: Reactodia.WorkspaceContext +): void { + const {overlay} = context; + overlay.showDialog({ + style: { + caption: 'SPARQL connection settings', + defaultSize: {width: 400, height: 600}, + resizableBy: 'all', + closable: Boolean(initialSettings), + }, + content: ( + { + overlay.hideDialog(); + applySettings(settings); + }} + /> + ), + }); +} + +function SparqlConnectionForm(props: { + initialSettings: SparqlConnectionSettings | undefined; + onSubmit: (settings: SparqlConnectionSettings) => void; +}) { + const {initialSettings, onSubmit} = props; + const [draft, setDraft] = React.useState(() => ({ + endpointUrl: initialSettings?.endpointUrl ?? '', + graphText: initialSettings?.defaultGraphIris?.join(' ') ?? '', + username: initialSettings?.username ?? '', + password: initialSettings?.password ?? '', + })); + const passwordInputRef = React.useRef(null); + const [focusPasswordToken, setFocusPasswordToken] = React.useState(0); + React.useEffect(() => { + if (focusPasswordToken > 0) { + passwordInputRef.current?.focus(); + } + }, [focusPasswordToken]); + 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; + setDraft(previous => ({...previous, endpointUrl})); + }} + onKeyDown={submitOnEnter} + /> + {isValidEndpoint ? null : ( +
+ Invalid URL +
+ )} +
+
+ + { + 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} + /> +
+ { + 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 + 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. +
+
+
+ +
+
+ ); +}