diff --git a/.env_sample b/.env_sample index 6738d3a56..591bd0679 100644 --- a/.env_sample +++ b/.env_sample @@ -17,3 +17,8 @@ OWNER_STATE_OR_PROVINCE=Denmark OWNER_COUNTRY_NAME=DK MAX_CONTENT_LENGTH=2097152 + +# the assistant's plan service image tag (ghcr.io/atomgraph/webalgebra-server-ldh); `local` for an image built from a REST-VKG checkout +# WEBALGEBRA_VERSION=latest +# the OpenAI model the assistant plans and writes queries with; gpt-4o when unset +# OPENAI_MODEL=gpt-4o diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 385670d70..7480507ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,6 +161,13 @@ jobs: # strip the literal prefix echo "FULL_VERSION=${RAW_REF#linkeddatahub-}" >> $GITHUB_ENV + # The CLI resolves linkeddatahub-rdf at its own version, which the tagged checkout holds. Maven + # Central does not: release.sh deploys the library after pushing the tag, and Central publishes + # it minutes later, while this job runs seconds after the push. + - name: Build the linkeddatahub-rdf library + run: mvn -B install + working-directory: rdf + # release.sh keeps cli/pom.xml at the platform version, so the tagged commit already carries it # and the jar manifest ldh --version reads gets it from there - name: Build the ldh CLI diff --git a/AGENTS.md b/AGENTS.md index d0d322beb..0ae9e0ee1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,12 @@ The dataspace exposes a **read-only SPARQL 1.1 Query** endpoint (advertised via Write portable, standard SPARQL: use explicit `GRAPH` patterns, no engine-specific extensions. +**The default graph is empty.** Every document is a named graph whose name is the document's URL, so a triple pattern outside `GRAPH` matches nothing. Put the patterns inside `GRAPH ?g { ... }` to query across every document, or inside `GRAPH { ... }` for one document; a subquery needs its own `GRAPH` as well. A query that returns no rows without a `GRAPH` clause is not evidence that the data is absent. + +**One document per resource, so a join crosses graphs.** A resource is described in the document about it (`foaf:primaryTopic`), and the resources it links to - an order's customer, a product's category, an employee's manager - are each described in their own document, in another graph. A pattern that follows such a link therefore needs a `GRAPH` of its own for each resource: `GRAPH ?o { ?order schema:customer ?customer } GRAPH ?c { ?customer schema:legalName ?name }`, never both triples in one `GRAPH ?g`, which asks for them in the same document and matches nothing. Only a resource's own companions - its address, its contact point, an order's line items - share its document. When in doubt, give every subject variable its own graph variable: it costs nothing when two resources do share a document, and it is the only pattern that works when they do not. + +**A property is used on instances of its domain.** The ontology (`Link rel=lds:ontology`) declares each property's `rdfs:domain` and `rdfs:range`, and the data follows it: a property declared on `schema:Person` is not on the `schema:Corporation` that employs the person. To reach it, follow the ontology's path - the corporation's `schema:employee`, then the person's `schema:address`. + Outbound `SERVICE` and `LOAD` — from the triplestore and from the platform alike — are routed through the `egress` forward proxy, which refuses loopback, private and link-local destinations. A federated query reaches public endpoints and cannot reach the deployment's own services. With no proxy configured and `ALLOW_INTERNAL_URLS` unset, in-JVM `SERVICE` (in a `PATCH` update or an import mapping) is disabled outright. ## Content & document model diff --git a/CHANGELOG.md b/CHANGELOG.md index dd28de1de..57cfcbab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## [Unreleased] +### Migration +- Top-level LDH stylesheet params and variables carry the `ldh:` prefix (e.g. `$ldh:select-labelled-string`); an override of the unprefixed name is silently ignored +- Shared stylesheets query through `ldh:query-result()`; `ldh:send-request()` exists only server-side, and `ac:value-intersect()`/`ac:value-except()` are removed + +### Added +- An assistant for a signed-in reader: a request in natural language becomes a Web-Algebra plan, shown with its operations and its XML, and runs only on Execute; the steps, the documents the plan wrote and its result are reported as they happen, and the result is answered in words and drawn as a view draws it: a table, a list, a grid, a chart, a map or a 3D graph +- A conversation is an `ldh:Chat` block, placed in the document by an object block: the create bar's Assistant button starts one, each ends in its own composer, and every turn is stored as an `ldh:ChatTurn` with its plan and what the execution reported, so a chat is there when the reader comes back +- A `web-algebra` service in the stack (`ghcr.io/atomgraph/webalgebra-server-ldh`), reached through nginx at the reserved `/webalgebra` path with a WebID certificate on the connection; it acts for the reader through the secretary agent's delegation, so a plan may write exactly what its reader may. Needs the `openai_api_key` secret +- The agent guide (`AGENTS.md`) is served at `/AGENTS.md` on every dataspace origin, public and outside the dispatcher, so a client writing a query against the endpoint - the assistant's `SPARQLString` reads it as service documentation - learns that every document is a named graph and the default graph is empty + +### Changed +- An external HTML page with no RDF representation opens in a new tab; the proxy answers `406` instead of `502` +- Every chart, map and 3D graph canvas is initialised by one template, wherever it is rendered +- Web-Client dependency bumped to 6.0.5 + +### Fixed +- A malformed `Accept`-family header no longer turns an error response into a `500`; an invalid `accept` parameter is refused with `400` +- Proxied responses always vary on `Accept`, so a cached RDF response is no longer served to a browser navigation +- The proxy serves the boolean result of an upstream `ASK` query +- A document imported under another name than its ontology's no longer takes the dataspace down +- LDH's ontologies import SPIN, SP and OWL by the IRIs those ontologies declare +- Nested property lists of blank nodes lay out for their own cell instead of collapsing to one word per line +- Charts no longer fail on datetimes with fractional milliseconds +- The version history link announces that it opens a dialog + ## [6.1.0] - 2026-10-05 ### Migration - **BREAKING**: `ldh` drops `-b`/`--base`; dataspace-level commands take the base URI as their positional, defaulting to `LDH_BASE` diff --git a/CLAUDE.md b/CLAUDE.md index c2e17250f..94867e5ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,7 @@ The application runs as a multi-container setup: - **linkeddatahub**: Main Java application (Tomcat) - **fuseki**: One SPARQL server holding a TDB2 dataset per dataspace role (`config/fuseki/config.ttl`), named after the dataspace origin (deployment host dropped, role appended: `end-user`, `admin`, `northwind-traders.demo.end-user`, …), each under `fuseki//`; bound to apps in `config/system.trig` - **egress**: Squid forward proxy for the store's and platform's outbound requests (SPARQL `SERVICE`, `LOAD`): public destinations only, so a query cannot reach another dataset, Varnish or the platform +- **web-algebra**: REST-VKG's `webalgebra-server-ldh` image — executes Web-Algebra plans and writes them from natural language for the assistant drawer (`xsl/client/chat.xsl`). nginx forwards `/webalgebra` (a reserved path, like `/static/` and `/uploads/`) to it with the caller's certificate; it presents the secretary's certificate to this instance with `On-Behalf-Of` naming the caller, which `WebIDFilter` honours because every agent's WebID document carries ` acl:delegates `. It addresses the instance by its public origin, rewritten to `nginx:9443` (`WEBALGEBRA_PROXY_HOST`), the same trick the platform's own `ClientUriRewriteFilter` plays - **varnish-frontend/varnish-admin/varnish-end-user**: Caching layers (admin and end-user caches both front the single `fuseki`) ### Data Flow diff --git a/Dockerfile b/Dockerfile index a95246e1f..35072ea33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,9 @@ RUN mvn -B -Pstandalone dependency:go-offline COPY src /usr/src/platform/src +# the agent guide the WAR serves at /AGENTS.md (pom.xml, maven-war-plugin webResources) +COPY AGENTS.md /usr/src/platform/AGENTS.md + RUN mvn -Pstandalone clean install # ============================== diff --git a/README.md b/README.md index c6a90abbd..85b374178 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ The [`ldh` command line interface](#command-line-interface) is attached to every - `secrets/client_truststore_password.txt` - `secrets/owner_cert_password.txt` - `secrets/secretary_cert_password.txt` + - `secrets/openai_api_key.txt` — optional: an OpenAI API key for the assistant drawer, declared in `docker-compose.override.yml` like the OAuth secrets; without it the assistant runs plans but cannot write them The one you will need to remember in order to authenticate with LinkedDataHub using WebID client certificate is `owner_cert_password`. 5. Launch the application services by running this from command line: ```shell @@ -217,6 +218,8 @@ _:warning: Do not use blank nodes to identify applications or services. We recom
Password of the secretary's WebID certificate
client_truststore_password
Password of the client truststore
+
openai_api_key
+
OpenAI API key the web-algebra service writes the assistant's plans with. Optional, declared in the override together with OPENAI_API_KEY_FILE; the service executes plans without it, and the drawer then reports that a plan could not be written
google_client_id
Google's OAuth client ID
Login with Google authentication is enabled when this value is provided
diff --git a/cli/pom.xml b/cli/pom.xml index d78a8ae3d..ec5faef9e 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -4,7 +4,7 @@ com.atomgraph linkeddatahub-cli - 6.1.0 + 6.1.1-SNAPSHOT jar LinkedDataHub CLI diff --git a/docker-compose.yml b/docker-compose.yml index ff9d76b2e..6fadb1c27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ secrets: file: ./secrets/secretary_cert_password.txt client_truststore_password: file: ./secrets/client_truststore_password.txt + # openai_api_key: + # file: ./secrets/openai_api_key.txt # google_client_id: # file: ./secrets/google/client_id.txt # google_client_secret: @@ -28,6 +30,8 @@ services: depends_on: linkeddatahub: condition: service_healthy + web-algebra: + condition: service_started # its upstream has to resolve when nginx starts ports: - ${HTTP_PORT}:8080 # allow Tomcat to do HTTP to HTTPS redirect - ${HTTPS_PORT}:8443 # HTTPS @@ -149,6 +153,38 @@ services: target: /etc/squid/squid.conf expose: - 3128 + web-algebra: # runs Web-Algebra plans against this instance, and writes them from natural language for the assistant drawer + image: ghcr.io/atomgraph/webalgebra-server-ldh:${WEBALGEBRA_VERSION:-latest} + mem_limit: 1024m + restart: on-failure + depends_on: + - egress + expose: + - 8080 # internal only: nginx is the sole client, and the server trusts the Client-Cert header only because nginx sets it + environment: + # the origin that receives an identity - this instance, with every dataspace subdomain under it. Its public name is + # not a route to it from inside the network, so requests for it go to nginx's client-cert listener under that name + - WEBALGEBRA_TRUSTED_ORIGIN=${PROTOCOL}://${HOST}:${HTTPS_PORT} + - WEBALGEBRA_PROXY_HOST=nginx + - WEBALGEBRA_PROXY_PORT=9443 + # the secretary's WebID: the agent every user's WebID document already delegates (root-owner.trig.template, SignUp), + # so a plan submitted by a user runs under that user's own access control, not the secretary's + - WEBALGEBRA_KEYSTORE=/var/webalgebra/ssl/secretary/keystore.p12 + - WEBALGEBRA_KEYSTORE_PASSWORD_FILE=/run/secrets/secretary_cert_password + - WEBALGEBRA_TRUSTSTORE=/var/webalgebra/ssl/server/server.crt # the self-signed server certificate, as PEM + # the model key is optional, like the OAuth secrets: a deployment that writes plans declares the openai_api_key + # secret and this variable in its override; without them the service runs plans and declines to write any + # - OPENAI_API_KEY_FILE=/run/secrets/openai_api_key + - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o} + # public destinations (SPARQL endpoints a plan names, the model's API) go through egress like the platform's own; nginx + # is reached directly, since egress refuses internal addresses + - JAVA_TOOL_OPTIONS=-Dhttp.proxyHost=${EGRESS_PROXY_HOST:-egress} -Dhttp.proxyPort=3128 -Dhttps.proxyHost=${EGRESS_PROXY_HOST:-egress} -Dhttps.proxyPort=3128 -Dhttp.nonProxyHosts=nginx + secrets: + - secretary_cert_password + # - openai_api_key + volumes: + - ./ssl/secretary:/var/webalgebra/ssl/secretary:ro + - ./ssl/server:/var/webalgebra/ssl/server:ro varnish-frontend: image: varnish:7.7.3 user: root # otherwise varnish user does not have permissions to the mounted folder which is owner by root @@ -200,6 +236,10 @@ configs: } http { + upstream web-algebra { + server web-algebra:8080; + } + upstream linkeddatahub { server ${NGINX_UPSTREAM_SERVER:-varnish-frontend:6060}; } @@ -217,6 +257,7 @@ configs: limit_req_zone $$limit_key zone=linked_data:10m rate=15r/s; limit_req_zone $$limit_key zone=static_files:10m rate=20r/s; + limit_req_zone $$limit_key zone=web_algebra:10m rate=5r/s; # a plan is a model call; polling is what bursts limit_req_status 429; client_max_body_size ${MAX_CONTENT_LENGTH:-2097152}; @@ -231,6 +272,30 @@ configs: ssl_prefer_server_ciphers on; ssl_verify_client ${NGINX_SSL_VERIFY_CLIENT:-optional_no_ca}; + # the assistant's plan service. A reserved path, like /static/ and /uploads/: prefix-matched ahead of the + # generic location and never rewritten, so /webalgebra, /webalgebra/plans and /webalgebra/{id}/result reach the + # sidecar as they are. Reachable only with a WebID certificate on the connection: the sidecar acts for the + # WebID in the certificate nginx forwards, and a caller without one has nobody to act for. On-Behalf-Of is + # cleared for the same reason Client-Cert is - identity comes from the connection, never from a header + location ^~ /webalgebra { + if ($$ssl_client_verify = NONE) { + return 401; + } + + proxy_pass http://web-algebra; + limit_req zone=web_algebra burst=10 nodelay; + proxy_read_timeout 120s; # writing a plan is a 10-30 s model call + + proxy_set_header Host $$host; + proxy_set_header X-Forwarded-Host $$host; + proxy_set_header X-Forwarded-Proto $$scheme; + proxy_set_header X-Forwarded-Port ${HTTPS_PORT}; + + proxy_set_header Client-Cert ''; + proxy_set_header Client-Cert $$ssl_client_escaped_cert; + proxy_set_header On-Behalf-Of ''; + } + location / { add_header Access-Control-Allow-Origin "*" always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" always; diff --git a/pom.xml b/pom.xml index 7719ca451..99b587c21 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ com.atomgraph linkeddatahub - 6.1.0 + 6.1.1-SNAPSHOT ${packaging.type} AtomGraph LinkedDataHub @@ -46,7 +46,7 @@ https://github.com/AtomGraph/LinkedDataHub scm:git:git://github.com/AtomGraph/LinkedDataHub.git scm:git:git@github.com:AtomGraph/LinkedDataHub.git - linkeddatahub-6.1.0 + linkeddatahub-5.5.4 @@ -169,13 +169,13 @@ ${project.groupId} client - 6.0.3 + 6.0.5 classes ${project.groupId} client - 6.0.3 + 6.0.5 war @@ -399,6 +399,17 @@ ${project.build.directory}/${build.warName} true true + + + + ${basedir} + + AGENTS.md + + + ${project.groupId} diff --git a/rdf/pom.xml b/rdf/pom.xml index 1a66ffd6f..2244a400f 100644 --- a/rdf/pom.xml +++ b/rdf/pom.xml @@ -4,7 +4,7 @@ com.atomgraph linkeddatahub-rdf - 6.1.0 + 6.1.1-SNAPSHOT jar LinkedDataHub RDF diff --git a/rdf/src/main/java/com/atomgraph/linkeddatahub/rdf/vocabulary/LDH.java b/rdf/src/main/java/com/atomgraph/linkeddatahub/rdf/vocabulary/LDH.java index c4f2e0637..00953ffa9 100644 --- a/rdf/src/main/java/com/atomgraph/linkeddatahub/rdf/vocabulary/LDH.java +++ b/rdf/src/main/java/com/atomgraph/linkeddatahub/rdf/vocabulary/LDH.java @@ -45,6 +45,10 @@ private LDH() { } public static final Resource CSVImport = ResourceFactory.createResource(NS + "CSVImport"); /** ldh:RDFImport class */ public static final Resource RDFImport = ResourceFactory.createResource(NS + "RDFImport"); + /** ldh:Chat class: a conversation with the assistant, its turns the rdf:_N members */ + public static final Resource Chat = ResourceFactory.createResource(NS + "Chat"); + /** ldh:ChatTurn class: one question and what came of it */ + public static final Resource ChatTurn = ResourceFactory.createResource(NS + "ChatTurn"); /** ldh:MissingPropertyValue constraint class */ public static final Resource MissingPropertyValue = ResourceFactory.createResource(NS + "MissingPropertyValue"); /** ldh:ChildrenView resource */ @@ -62,6 +66,16 @@ private LDH() { } public static final Property delimiter = ResourceFactory.createProperty(NS + "delimiter"); /** ldh:chartType property */ public static final Property chartType = ResourceFactory.createProperty(NS + "chartType"); + /** ldh:question property: what a chat turn asked */ + public static final Property question = ResourceFactory.createProperty(NS + "question"); + /** ldh:answer property: the assistant's answer in words */ + public static final Property answer = ResourceFactory.createProperty(NS + "answer"); + /** ldh:plan property: the turn's Web-Algebra plan, an rdf:XMLLiteral */ + public static final Property plan = ResourceFactory.createProperty(NS + "plan"); + /** ldh:execution property: what running the plan reported, an rdf:XMLLiteral */ + public static final Property execution = ResourceFactory.createProperty(NS + "execution"); + /** ldh:outcome property: how the turn went, in one line */ + public static final Property outcome = ResourceFactory.createProperty(NS + "outcome"); /** ldh:categoryVarName property */ public static final Property categoryVarName = ResourceFactory.createProperty(NS + "categoryVarName"); /** ldh:seriesVarName property */ diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java index 222eb7a99..c56b8b978 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/Application.java +++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java @@ -47,6 +47,7 @@ import com.atomgraph.core.io.ModelProvider; import com.atomgraph.core.io.QueryProvider; import com.atomgraph.core.io.ResultSetProvider; +import com.atomgraph.core.io.SPARQLResultProvider; import com.atomgraph.core.io.UpdateRequestProvider; import com.atomgraph.core.mapper.BadGatewayExceptionMapper; import com.atomgraph.core.provider.QueryParamProvider; @@ -94,7 +95,7 @@ import com.atomgraph.linkeddatahub.server.factory.AgentContextFactory; import com.atomgraph.linkeddatahub.server.factory.ApplicationFactory; import com.atomgraph.linkeddatahub.server.factory.AuthorizationContextFactory; -import com.atomgraph.linkeddatahub.server.filter.request.AcceptLanguageFilter; +import com.atomgraph.linkeddatahub.server.filter.request.AcceptHeadersFilter; import com.atomgraph.linkeddatahub.server.filter.request.ApplicationFilter; import com.atomgraph.linkeddatahub.server.filter.request.auth.WebIDFilter; import com.atomgraph.linkeddatahub.server.io.ValidatingModelProvider; @@ -1072,6 +1073,7 @@ public void init() register(new ValidatingModelProvider(getMessageDigest())); register(new ResultSetProvider()); + register(new SPARQLResultProvider()); // the boolean of an ASK, which ResultSetProvider cannot write register(new QueryProvider()); register(new QueryParamProvider()); register(new UpdateRequestProvider()); @@ -1222,7 +1224,7 @@ protected void registerResourceClasses() protected void registerContainerRequestFilters() { register(new HttpMethodOverrideFilter()); - register(AcceptLanguageFilter.class); + register(AcceptHeadersFilter.class); register(ApplicationFilter.class); register(OntologyFilter.class); register(ProxiedWebIDFilter.class); @@ -1687,22 +1689,6 @@ public static Client getClient(KeyStore keyStore, String keyStorePassword, KeySt PoolingHttpClientConnectionManager conman = new PoolingHttpClientConnectionManager(socketFactoryRegistry, null, null, null, connectionTimeToLive != null ? connectionTimeToLive : -1L, TimeUnit.MILLISECONDS) { - // https://github.com/eclipse-ee4j/jersey/issues/4449 - - @Override - public void close() - { - super.shutdown(); - } - - @Override - public void shutdown() - { - // Disable shutdown of the pool. This will be done later, when this factory is closed - // This is a workaround for finalize method on jerseys ClientRuntime which - // closes the client and shuts down the connection pool when it is garbage collected - }; - // https://github.com/eclipse-ee4j/jersey/issues/2855 @Override @@ -1723,11 +1709,13 @@ public void releaseConnection(final HttpClientConnection managedConn, final Obje config.register(new ModelProvider()); config.register(new DatasetProvider()); config.register(new ResultSetProvider()); + config.register(new SPARQLResultProvider()); config.register(new QueryProvider()); config.register(new UpdateRequestProvider()); config.property(ClientProperties.FOLLOW_REDIRECTS, true); config.property(ClientProperties.REQUEST_ENTITY_PROCESSING, RequestEntityProcessing.BUFFERED); // https://stackoverflow.com/questions/42139436/jersey-client-throws-cannot-retry-request-with-a-non-repeatable-request-entity config.property(ApacheClientProperties.CONNECTION_MANAGER, conman); + config.property(ApacheClientProperties.CONNECTION_MANAGER_SHARED, true); // the pool is not the connector's to shut down: a target with its own filters runs on a connector of its own, which Jersey closes once it is collected - https://github.com/eclipse-ee4j/jersey/issues/4449 RequestConfig.Builder requestConfig = RequestConfig.custom(); if (connectionRequestTimeout != null) requestConfig.setConnectionRequestTimeout(connectionRequestTimeout); if (socketTimeout != null) requestConfig.setSocketTimeout(socketTimeout); @@ -1795,22 +1783,6 @@ public static Client getNoCertClient(KeyStore trustStore, Integer maxConnPerRout PoolingHttpClientConnectionManager conman = new PoolingHttpClientConnectionManager(socketFactoryRegistry, null, null, null, connectionTimeToLive != null ? connectionTimeToLive : -1L, TimeUnit.MILLISECONDS) { - // https://github.com/eclipse-ee4j/jersey/issues/4449 - - @Override - public void close() - { - super.shutdown(); - } - - @Override - public void shutdown() - { - // Disable shutdown of the pool. This will be done later, when this factory is closed - // This is a workaround for finalize method on jerseys ClientRuntime which - // closes the client and shuts down the connection pool when it is garbage collected - }; - // https://github.com/eclipse-ee4j/jersey/issues/2855 @Override @@ -1831,11 +1803,13 @@ public void releaseConnection(final HttpClientConnection managedConn, final Obje config.register(new ModelProvider()); config.register(new DatasetProvider()); config.register(new ResultSetProvider()); + config.register(new SPARQLResultProvider()); config.register(new QueryProvider()); config.register(new UpdateRequestProvider()); // TO-DO: UpdateRequestProvider config.property(ClientProperties.FOLLOW_REDIRECTS, true); config.property(ClientProperties.REQUEST_ENTITY_PROCESSING, RequestEntityProcessing.BUFFERED); // https://stackoverflow.com/questions/42139436/jersey-client-throws-cannot-retry-request-with-a-non-repeatable-request-entity config.property(ApacheClientProperties.CONNECTION_MANAGER, conman); + config.property(ApacheClientProperties.CONNECTION_MANAGER_SHARED, true); // the pool is not the connector's to shut down: a target with its own filters runs on a connector of its own, which Jersey closes once it is collected - https://github.com/eclipse-ee4j/jersey/issues/4449 RequestConfig.Builder requestConfig = RequestConfig.custom(); if (connectionRequestTimeout != null) requestConfig.setConnectionRequestTimeout(connectionRequestTimeout); if (socketTimeout != null) requestConfig.setSocketTimeout(socketTimeout); @@ -1914,22 +1888,6 @@ public static Client getVerifiedClient(Integer maxConnPerRoute, Integer maxTotal PoolingHttpClientConnectionManager conman = new PoolingHttpClientConnectionManager(socketFactoryRegistry, null, null, null, connectionTimeToLive != null ? connectionTimeToLive : -1L, TimeUnit.MILLISECONDS) { - // https://github.com/eclipse-ee4j/jersey/issues/4449 - - @Override - public void close() - { - super.shutdown(); - } - - @Override - public void shutdown() - { - // Disable shutdown of the pool. This will be done later, when this factory is closed - // This is a workaround for finalize method on jerseys ClientRuntime which - // closes the client and shuts down the connection pool when it is garbage collected - }; - // https://github.com/eclipse-ee4j/jersey/issues/2855 @Override @@ -1949,6 +1907,7 @@ public void releaseConnection(final HttpClientConnection managedConn, final Obje config.property(ClientProperties.FOLLOW_REDIRECTS, true); config.property(ClientProperties.REQUEST_ENTITY_PROCESSING, RequestEntityProcessing.BUFFERED); // https://stackoverflow.com/questions/42139436/jersey-client-throws-cannot-retry-request-with-a-non-repeatable-request-entity config.property(ApacheClientProperties.CONNECTION_MANAGER, conman); + config.property(ApacheClientProperties.CONNECTION_MANAGER_SHARED, true); // the pool is not the connector's to shut down: a target with its own filters runs on a connector of its own, which Jersey closes once it is collected - https://github.com/eclipse-ee4j/jersey/issues/4449 RequestConfig.Builder requestConfig = RequestConfig.custom(); if (connectionRequestTimeout != null) requestConfig.setConnectionRequestTimeout(connectionRequestTimeout); if (socketTimeout != null) requestConfig.setSocketTimeout(socketTimeout); diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/AcceptHeadersFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/AcceptHeadersFilter.java new file mode 100644 index 000000000..b85916160 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/AcceptHeadersFilter.java @@ -0,0 +1,82 @@ +/** + * Copyright 2026 Martynas Jusevičius + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.atomgraph.linkeddatahub.server.filter.request; + +import jakarta.annotation.Priority; +import jakarta.ws.rs.ProcessingException; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.PreMatching; +import jakarta.ws.rs.core.HttpHeaders; +import java.io.IOException; +import java.util.Map; +import java.util.function.Consumer; +import org.glassfish.jersey.server.ContainerRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Drops each content negotiation header that does not parse - Accept, Accept-Language, + * Accept-Charset and Accept-Encoding - so the request proceeds as if the client stated no + * preference on that axis. + * + * These headers are advisory, so a malformed value is no reason to refuse the request. It is also not something a + * later stage can recover from: JAX-RS parses them lazily, all four on every Request.selectVariant() + * call, and every exception mapper's response is built through selectVariant(). A parse failure raised + * inside a mapper escapes it, and what the client gets is the container's 500 in place of the status the mapper meant + * to send - a 403 for an unauthorized request, say. The headers are read from the request's live header map each time, + * so removing one here, before any filter that can throw into a mapper, is what makes every later reader see a request + * with no preference instead. + * The accept query parameter, which {@link ApplicationFilter} copies into the Accept header + * later, is checked there. + * + * @author Martynas Jusevičius {@literal } + */ +@PreMatching +@Priority(200) // before ApplicationFilter (700) and anything else that can fail into an exception mapper; after ContentLengthLimitFilter (100) +public class AcceptHeadersFilter implements ContainerRequestFilter +{ + + private static final Logger log = LoggerFactory.getLogger(AcceptHeadersFilter.class); + + /** each header with the Jersey call that parses it; JAX-RS has no parsed getter for the charset and encoding */ + private static final Map> PARSERS = Map.of( + HttpHeaders.ACCEPT, ContainerRequest::getAcceptableMediaTypes, + HttpHeaders.ACCEPT_LANGUAGE, ContainerRequest::getAcceptableLanguages, + HttpHeaders.ACCEPT_CHARSET, ContainerRequest::getQualifiedAcceptCharset, + HttpHeaders.ACCEPT_ENCODING, ContainerRequest::getQualifiedAcceptEncoding); + + @Override + public void filter(ContainerRequestContext crc) throws IOException + { + ContainerRequest request = (ContainerRequest)crc; // Jersey's implementation of the context + + PARSERS.forEach((header, parser) -> + { + try + { + parser.accept(request); + } + catch (ProcessingException ex) // Jersey's HeaderValueException, caught by its JAX-RS supertype + { + if (log.isDebugEnabled()) log.debug("Ignoring malformed {} header value: '{}'", header, request.getHeaderString(header)); + request.getHeaders().remove(header); + } + }); + } + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/AcceptLanguageFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/AcceptLanguageFilter.java deleted file mode 100644 index 884c56005..000000000 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/AcceptLanguageFilter.java +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Copyright 2026 Martynas Jusevičius - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.atomgraph.linkeddatahub.server.filter.request; - -import jakarta.annotation.Priority; -import jakarta.ws.rs.ProcessingException; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.container.ContainerRequestFilter; -import jakarta.ws.rs.container.PreMatching; -import jakarta.ws.rs.core.HttpHeaders; -import java.io.IOException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Drops an Accept-Language header that does not parse, so the request proceeds as if the client stated - * no language preference. - * - * The header is advisory, so a malformed value is no reason to refuse the request. It is also not something a later - * stage can recover from: JAX-RS parses it lazily, on every Request.selectVariant() and - * getAcceptableLanguages() call, and every exception mapper's response is built through - * selectVariant(). A parse failure raised inside a mapper escapes it, and what the client gets is the - * container's 500 in place of the status the mapper meant to send - a 403 for an unauthorized request, say. The - * header is read from the request's live header map each time, so removing it here, before any filter that can - * throw into a mapper, is what makes every later reader see a request with no preference instead. - * - * @author Martynas Jusevičius {@literal } - */ -@PreMatching -@Priority(200) // before ApplicationFilter (700) and anything else that can fail into an exception mapper; after ContentLengthLimitFilter (100) -public class AcceptLanguageFilter implements ContainerRequestFilter -{ - - private static final Logger log = LoggerFactory.getLogger(AcceptLanguageFilter.class); - - @Override - public void filter(ContainerRequestContext crc) throws IOException - { - try - { - crc.getAcceptableLanguages(); - } - catch (ProcessingException ex) // Jersey's HeaderValueException, caught by its JAX-RS supertype so no internal class is named - { - if (log.isDebugEnabled()) log.debug("Ignoring malformed Accept-Language header value: '{}'", crc.getHeaderString(HttpHeaders.ACCEPT_LANGUAGE)); - crc.getHeaders().remove(HttpHeaders.ACCEPT_LANGUAGE); - } - } - -} diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ApplicationFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ApplicationFilter.java index c5a997fdf..f5ddaf86b 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ApplicationFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ApplicationFilter.java @@ -32,6 +32,7 @@ import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.container.PreMatching; import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MultivaluedHashMap; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.UriBuilder; @@ -70,7 +71,7 @@ public void filter(ContainerRequestContext request) throws IOException // set early so content negotiation works even if app matching fails if (request.getUriInfo().getQueryParameters().containsKey(AC.accept.getLocalName())) - request.getHeaders().putSingle(HttpHeaders.ACCEPT, request.getUriInfo().getQueryParameters().getFirst(AC.accept.getLocalName())); + setAccept(request, request.getUriInfo().getQueryParameters().getFirst(AC.accept.getLocalName())); // there always have to be an app Resource appResource = getSystem().matchApp(request.getUriInfo().getAbsolutePath()); @@ -156,6 +157,32 @@ public void filter(ContainerRequestContext request) throws IOException } } + /** + * Replaces the Accept header with the value of the accept query parameter. + * The parameter names a single media type, which DocumentHierarchyGraphStoreImpl.getWritableMediaTypes() + * parses as one. A value that does not parse as one is refused with 400 Bad Request before the header is touched: + * the exception mapper that writes the 400 negotiates its response through that header, and would fail on the + * malformed value and send a 500 instead. + * The typical malformed value is application/ld json, from a link that left the + of + * application/ld+json unencoded. + * + * @param request request context + * @param accept query parameter value + */ + public void setAccept(ContainerRequestContext request, String accept) + { + try + { + MediaType.valueOf(accept); + } + catch (IllegalArgumentException ex) + { + throw new BadRequestException("Malformed '" + AC.accept.getLocalName() + "' query parameter value: '" + accept + "'", ex); + } + + request.getHeaders().putSingle(HttpHeaders.ACCEPT, accept); + } + /** * Returns the system application. * diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java index 39947e9c7..132423d46 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java @@ -26,8 +26,10 @@ import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import jakarta.annotation.Priority; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; @@ -47,6 +49,7 @@ import org.apache.jena.vocabulary.RDF; import org.apache.jena.vocabulary.RDFS; import org.apache.jena.vocabulary.OWL; +import org.apache.jena.vocabulary.OWL2; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -274,6 +277,26 @@ public static List declarePackageImports(PrefixGraphRepository repositor return imports; } + /** + * Returns the URIs the graph declares as its ontology names: the subjects of its {@code owl:Ontology} + * headers and their {@code owl:versionIRI}s. An import resolves cleanly when its URI is one of them; a + * document that declares names but not the imported URI is a document imported by its location while + * naming an ontology elsewhere. + * + * @param graph ontology graph + * @return declared ontology URIs, empty if the graph carries no header + */ + public static Set getOntologyNames(Graph graph) + { + Set names = new HashSet<>(); + graph.find(Node.ANY, RDF.type.asNode(), OWL.Ontology.asNode()).mapWith(Triple::getSubject).filterKeep(Node::isURI).forEach(ontology -> + { + names.add(ontology.getURI()); + graph.find(ontology, OWL2.versionIRI.asNode(), Node.ANY).mapWith(Triple::getObject).filterKeep(Node::isURI).forEach(version -> names.add(version.getURI())); + }); + return names; + } + /** * Returns the graph the repository supplies for the given ID, or null if it cannot supply one. * @@ -309,7 +332,8 @@ public static UnionGraph loadOntology(PrefixGraphRepository repository, String u { if (log.isDebugEnabled()) log.debug("Started loading ontology with URI '{}'", uri); ScopedGraphRepository scoped = new ScopedGraphRepository(repository); - OntModel ontology = OntModelFactory.createModel(repository.get(uri), OntSpecification.OWL2_FULL_MEM, scoped); + // the root is read through the view too: it is the graph ontapi writes the imports it reconciles into + OntModel ontology = OntModelFactory.createModel(scoped.get(uri), OntSpecification.OWL2_FULL_MEM, scoped); UnionGraph union = (UnionGraph)ontology.getGraph(); // promote rdfs:Class to owl:Class so the OWL2 profile recognises third-party vocab terms (e.g. sp:Describe // in sp.ttl) as named classes. The promotions live in their own union member so no document graph is diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java index dec28354f..790af0f97 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java @@ -25,6 +25,7 @@ import com.atomgraph.linkeddatahub.client.GraphStoreClient; import com.atomgraph.linkeddatahub.client.filter.auth.IDTokenDelegationFilter; import com.atomgraph.linkeddatahub.client.filter.auth.WebIDDelegationFilter; +import com.atomgraph.linkeddatahub.io.HtmlJsonLDReaderFactory; import com.atomgraph.linkeddatahub.server.security.AgentContext; import com.atomgraph.linkeddatahub.server.security.IDTokenSecurityContext; import com.atomgraph.linkeddatahub.server.security.WebIDSecurityContext; @@ -34,7 +35,9 @@ import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Optional; import java.util.Set; import jakarta.annotation.Priority; @@ -62,12 +65,14 @@ import org.apache.jena.riot.RDFLanguages; import org.apache.jena.riot.RiotException; import org.apache.jena.riot.resultset.ResultSetReaderRegistry; +import org.apache.jena.sparql.resultset.SPARQLResult; import org.glassfish.jersey.message.internal.MessageBodyProviderNotFoundException; import java.util.regex.Pattern; import jakarta.ws.rs.NotAcceptableException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.atomgraph.core.io.ModelProvider; +import com.atomgraph.core.io.SPARQLResultProvider; import com.atomgraph.core.util.ResultSetUtils; /** @@ -119,12 +124,12 @@ public class ProxyRequestFilter implements ContainerRequestFilter * End-to-end response headers forwarded verbatim from the upstream. Excludes hop-by-hop headers * (RFC 7230 §6.1), framing headers re-emitted by the container, origin-bound security headers * (CSP, HSTS, CORS), cookies, and {@code Content-Type}/{@code Link} which are set explicitly. + * {@code Vary} is not among them: it is merged, see {@link #mergeVary(String, String)}. */ private static final Set FORWARDED_RESPONSE_HEADERS = Set.of( HttpHeaders.ETAG, HttpHeaders.LAST_MODIFIED, HttpHeaders.CACHE_CONTROL, - HttpHeaders.VARY, HttpHeaders.EXPIRES, HttpHeaders.CONTENT_LANGUAGE, HttpHeaders.CONTENT_DISPOSITION, @@ -300,8 +305,8 @@ protected Optional resolveTargetURI(ContainerRequestContext requestContext) * Dispatches on upstream {@code Content-Type} via Jena's live RIOT registry: *
    *
  • {@link RDFLanguages#contentTypeToLang} + {@link ResultSetReaderRegistry#isRegistered} - * → parse as {@code ResultSetRewindable}, re-serialize through - * {@link #getResponse(ResultSetRewindable, Response.StatusType)};
  • + * → parse as {@code SPARQLResult}, re-serialize through + * {@link #getResponse(SPARQLResult, Response.StatusType)}: a result set, or the boolean an ASK answers with; *
  • {@link RDFLanguages#contentTypeToLang} only → parse as {@code Model}, re-serialize * through {@link #getResponse(Model, Response.StatusType)} so the client gets the * format it asked for via {@code Accept};
  • @@ -384,15 +389,32 @@ protected Response getResponse(Response clientResponse, URI targetURI, String me if (lang != null && ResultSetReaderRegistry.isRegistered(lang)) { - ResultSetRewindable results = clientResponse.readEntity(ResultSetRewindable.class); - return overlayHeaders(getResponse(results, clientResponse.getStatusInfo()), clientResponse, false); + // a results body is a result set or, when the upstream answered an ASK, a boolean; the content type does not + // say which, and reading a boolean as a result set throws + SPARQLResult result = clientResponse.readEntity(SPARQLResult.class); + return overlayHeaders(getResponse(result, clientResponse.getStatusInfo()), clientResponse, false); } if (lang != null) { // base URI hint so ModelProvider (and HtmlJsonLDReader through it) resolve relative IRIs against the upstream URI clientResponse.getHeaders().putSingle(ModelProvider.REQUEST_URI_HEADER, targetURI.toString()); - Model model = clientResponse.readEntity(Model.class); + Model model; + try + { + model = clientResponse.readEntity(Model.class); + } + catch (RiotException ex) + { + // an HTML page carrying no JSON-LD is a document for people with no RDF representation, which is what + // the caller asked for: 406, so the client opens the page itself. A 502 would say the origin failed + if (HtmlJsonLDReaderFactory.HTML.equals(lang)) + { + if (log.isDebugEnabled()) log.debug("Proxied URI {} returned HTML with no readable JSON-LD", targetURI); + throw new NotAcceptableException(ex); + } + throw ex; + } // forward the origin's validators (replacing the ones the Model builder stamps off the re-serialized // bytes): a client editing the proxied document sends If-Match through this proxy to the origin, which // compares against its own ETag - a re-serialization validator would 412 every proxied write. The proxy @@ -425,7 +447,7 @@ protected Response getResponse(Response clientResponse, URI targetURI, String me * @param copyValidators whether to forward {@code ETag} and {@code Last-Modified} * @return response with overlaid upstream headers */ - private Response overlayHeaders(Response response, Response clientResponse, boolean copyValidators) + protected Response overlayHeaders(Response response, Response clientResponse, boolean copyValidators) { Response.ResponseBuilder rb = Response.fromResponse(response); @@ -444,9 +466,39 @@ private Response overlayHeaders(Response response, Response clientResponse, bool if (value != null) rb.header(name, null).header(name, value); // replace, not append - the upstream value overlays any locally stamped one } + rb.header(HttpHeaders.VARY, null).header(HttpHeaders.VARY, mergeVary(response.getHeaderString(HttpHeaders.VARY), clientResponse.getHeaderString(HttpHeaders.VARY))); + return rb.build(); } + /** + * Merges the locally stamped {@code Vary} with the upstream's, always including {@code Accept}. The upstream's + * value describes how the upstream's representation varies, and the proxy's varies on more: this filter itself + * chooses between the application shell and the proxied body by {@code Accept}, and the typed branches re-serialize + * into the negotiated format. Replacing the local value with the upstream's let a cache store the proxied RDF under + * the bare URL — an upstream's {@code Cache-Control: max-age=300} with {@code Vary: Accept-Encoding} served the + * next browser navigation RDF/XML for five minutes. + * + * @param local the {@code Vary} stamped on the locally built response, or {@code null} + * @param upstream the upstream's {@code Vary}, or {@code null} + * @return the merged field list, or {@code *} if either side varies on everything + */ + protected static String mergeVary(String local, String upstream) + { + Set seen = new HashSet<>(); + List fields = new ArrayList<>(); + for (String value : new String[] { HttpHeaders.ACCEPT, local, upstream }) + if (value != null) + for (String field : value.split(",")) + { + String trimmed = field.trim(); + if (trimmed.equals("*")) return "*"; + if (!trimmed.isEmpty() && seen.add(trimmed.toLowerCase(Locale.ROOT))) fields.add(trimmed); // field names are case-insensitive + } + + return String.join(", ", fields); + } + /** * Builds a response for the given RDF model with type-appropriate content negotiation. * Used for locally-served responses (DataManager cache) and for @@ -475,6 +527,46 @@ protected Response getResponse(Model model, Response.StatusType statusType) build(); } + /** + * Builds a response for a SPARQL results body: a result set, or the boolean an ASK answers with. + * + * @param result SPARQL result as read from the upstream response + * @param statusType response status + * @return JAX-RS response + */ + protected Response getResponse(SPARQLResult result, Response.StatusType statusType) + { + if (result.isBoolean()) return getBooleanResponse(result, statusType); + if (result.isResultSet()) return getResponse((ResultSetRewindable)result.getResultSet(), statusType); // SPARQLResultProvider reads rows rewindable + + throw new IllegalArgumentException("A SPARQL results body holds a result set or a boolean"); + } + + /** + * Builds a response for the boolean result of an ASK, offering only the result set media types a boolean can be + * written in: Jena has no boolean encoding in Thrift or Protobuf, and (X)HTML has no writer for one. + * + * @param result boolean result + * @param statusType response status + * @return JAX-RS response + */ + protected Response getBooleanResponse(SPARQLResult result, Response.StatusType statusType) + { + if (!result.isBoolean()) throw new IllegalArgumentException("SPARQLResult is not a boolean"); + + return new com.atomgraph.core.model.impl.Response(getRequest(), + result, + null, + new EntityTag(Long.toHexString(ResultSetUtils.hashBoolean(result.getBooleanResult()))), + getMediaTypes().getWritable(ResultSet.class).stream().filter(SPARQLResultProvider::isBooleanWriteable).toList(), + getSystem().getSupportedLanguages(), + new ArrayList<>(), + new HTMLMediaTypePredicate()). + getResponseBuilder(). + status(statusType). + build(); + } + /** * Builds a response for the given SPARQL result set with type-appropriate content negotiation. * Used by the proxy's ResultSet branch when an upstream returns SPARQL results. diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/io/ValidatingModelProvider.java b/src/main/java/com/atomgraph/linkeddatahub/server/io/ValidatingModelProvider.java index 65bdcfb71..e8ab5f820 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/io/ValidatingModelProvider.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/io/ValidatingModelProvider.java @@ -18,8 +18,10 @@ import com.atomgraph.linkeddatahub.dataspaces.model.AdminDataspace; import com.atomgraph.linkeddatahub.dataspaces.model.EndUserDataspace; +import com.atomgraph.client.util.jena.PrefixGraphRepository; import com.atomgraph.linkeddatahub.client.GraphStoreClient; import com.atomgraph.linkeddatahub.model.auth.Agent; +import com.atomgraph.linkeddatahub.server.filter.request.OntologyFilter; import com.atomgraph.linkeddatahub.server.security.AgentContext; import com.atomgraph.linkeddatahub.vocabulary.ACL; import org.apache.jena.query.QueryFactory; @@ -50,6 +52,7 @@ import java.security.MessageDigest; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import jakarta.inject.Inject; @@ -58,6 +61,7 @@ import java.util.HashMap; import java.util.Map; import org.apache.commons.codec.binary.Hex; +import org.apache.jena.graph.Graph; import org.apache.jena.query.Query; import org.apache.jena.rdf.model.Statement; import org.apache.jena.rdf.model.StmtIterator; @@ -236,6 +240,8 @@ public Resource processRead(Resource resource) // this logic really belongs in a // clear cached raw graph and closure union graph if ontology is updated. TO-DO: send event instead getSystem().getRepository().remove(resource.getURI()); getSystem().getOntologyGraphs().remove(resource.getURI()); + + validateImports(resource); } if (getDataspace().isPresent() && resource.hasProperty(RDF.type, ACL.Authorization)) @@ -249,6 +255,42 @@ public Resource processRead(Resource resource) // this logic really belongs in a return resource; } + /** + * Rejects an owl:imports of a document that declares another ontology than the imported URI. + * An import names an ontology, and the URI has to dereference to a document declaring it - directly + * (skos/core) or as its hash URI (ns#, whose document is ns). A + * document imported by its location while naming an ontology elsewhere is imported under both names, + * and the second one resolves to whatever that URI dereferences to. An import that cannot be resolved, + * or resolves to a document without an ontology header, is let through: it contributes nothing, and + * whether a remote server answers is not the writer's error. + * + * @param ontology ontology resource + */ + public void validateImports(Resource ontology) + { + PrefixGraphRepository repository = getSystem().getRepository(getEndUserDataspace()); + List cvs = new ArrayList<>(); + + ontology.listProperties(OWL.imports).filterKeep(stmt -> stmt.getObject().isURIResource()).forEach(stmt -> + { + String importURI = stmt.getResource().getURI(); + Graph imported = OntologyFilter.resolve(repository, importURI); + if (imported == null) return; + + Set names = OntologyFilter.getOntologyNames(imported); + if (!names.isEmpty() && !names.contains(importURI)) + { + if (log.isDebugEnabled()) log.debug("Bad request - imported document <{}> declares the ontologies {}", importURI, names); + List paths = new ArrayList<>(); + paths.add(new ObjectPropertyPath(ontology, OWL.imports)); + String declared = names.stream().map(name -> "<" + name + ">").collect(Collectors.joining(", ")); + cvs.add(new ConstraintViolation(ontology, paths, null, "Imported document <" + importURI + "> declares the ontology " + declared + ", not <" + importURI + ">", null)); + } + }); + + if (!cvs.isEmpty()) throw new SPINConstraintViolationException(cvs, ontology.getModel()); + } + @Override public Model processWrite(Model model) { diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/ScopedGraphRepository.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/ScopedGraphRepository.java index 4fec524ed..ba5f43556 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/util/ScopedGraphRepository.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/ScopedGraphRepository.java @@ -17,11 +17,17 @@ package com.atomgraph.linkeddatahub.server.util; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.stream.Stream; import org.apache.jena.graph.Graph; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.compose.Delta; import org.apache.jena.ontapi.GraphRepository; +import org.apache.jena.ontapi.utils.Graphs; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * A graph repository view that reads through a shared backing repository but keeps writes local. @@ -35,6 +41,14 @@ * SPARQL-first/mapped/HTTP loading and raw caching as usual, so resolving an imports closure through * this view populates the shared raw cache as a side effect. *

    + * ontapi also writes into the graphs of the closure: attaching an import whose header names another + * ontology than the import URI does (a document imported by its location) adds an {@code owl:imports} + * of the declared name to the importing graph. Read straight from the shared repository, that write + * would outlive the closure, and the next closure built over the same cache would resolve both names + * into two graphs of one name, which ontapi refuses. Backing graphs are therefore handed out wrapped in + * a {@link Delta} owned by this view, so the writes stay with the closure and the shared graphs keep + * what their documents say. + *

    * After model construction, {@link #ids()} equals the set of resolved closure ontology IDs. * * @author Martynas Jusevičius {@literal } @@ -42,8 +56,13 @@ public class ScopedGraphRepository implements GraphRepository { + private static final Logger log = LoggerFactory.getLogger(ScopedGraphRepository.class); + private final GraphRepository backing; private final Map local = new HashMap<>(); + // keyed by backing graph instance, not by ID: a bundled document is one graph shared by every URI of its + // namespace, and ontapi tells graphs apart by identity, so two wrappers of one graph would be two graphs + private final Map overlays = new IdentityHashMap<>(); /** * Constructs the view over a shared backing repository. @@ -61,7 +80,20 @@ public Graph get(String id) Graph graph = local.get(id); if (graph != null) return graph; - return getBacking().get(id); + Graph backed = getBacking().get(id); + if (backed == null) return null; + + // never closed: closing a Delta closes the graph it wraps + return overlays.computeIfAbsent(backed, shared -> + { + // ontapi files a graph under the name its header declares, so a document that names another ontology + // than the URI it was resolved by is imported under a second name, which may resolve elsewhere + Graphs.findOntologyNameNode(shared).filter(Node::isURI).map(Node::getURI).filter(name -> !name.equals(id)).ifPresent(name -> + { + if (log.isWarnEnabled()) log.warn("Graph resolved by URI <{}> declares the ontology <{}>", id, name); + }); + return new Delta(shared); + }); } @Override @@ -86,6 +118,7 @@ public Graph remove(String id) public void clear() { local.clear(); + overlays.clear(); } @Override diff --git a/src/main/resources/com/atomgraph/linkeddatahub/def.ttl b/src/main/resources/com/atomgraph/linkeddatahub/def.ttl index e6f9520d3..9c15109f9 100644 --- a/src/main/resources/com/atomgraph/linkeddatahub/def.ttl +++ b/src/main/resources/com/atomgraph/linkeddatahub/def.ttl @@ -18,7 +18,7 @@ : a owl:Ontology ; rdfs:label "Default" ; rdfs:comment "Default document model" ; - owl:imports ldh:, lds:, sd:, sp: ; + owl:imports ldh:, lds:, sd:, ; owl:versionInfo "1.0-SNAPSHOT" . :Root a owl:Class ; diff --git a/src/main/resources/com/atomgraph/linkeddatahub/dh.ttl b/src/main/resources/com/atomgraph/linkeddatahub/dh.ttl index f3e29cec4..d58944233 100644 --- a/src/main/resources/com/atomgraph/linkeddatahub/dh.ttl +++ b/src/main/resources/com/atomgraph/linkeddatahub/dh.ttl @@ -13,7 +13,7 @@ @prefix foaf: . : a owl:Ontology ; - owl:imports foaf:, sioc:, sp: ; + owl:imports foaf:, sioc:, ; owl:versionInfo "2.0.0" ; rdfs:label "Document hierarchy" ; dct:created "2016-08-31T21:54:00+02:00"^^xsd:dateTime ; diff --git a/src/main/resources/com/atomgraph/linkeddatahub/ldh.ttl b/src/main/resources/com/atomgraph/linkeddatahub/ldh.ttl index 68766a73d..0043eff42 100644 --- a/src/main/resources/com/atomgraph/linkeddatahub/ldh.ttl +++ b/src/main/resources/com/atomgraph/linkeddatahub/ldh.ttl @@ -18,7 +18,7 @@ @prefix dct: . : a owl:Ontology ; - owl:imports dh:, ac:, , foaf:, sd:, nfo:, owl: ; + owl:imports dh:, ac:, , foaf:, sd:, nfo:, ; rdfs:label "LinkedDataHub ontology" ; rdfs:comment "Should be imported and reused by all extending applications" ; owl:versionInfo "1.1.7" . @@ -111,6 +111,41 @@ rdfs:comment "Whether the view block is shown when its query returns no results. Defaults to true" ; rdfs:isDefinedBy : . +:question a owl:DatatypeProperty ; + rdfs:domain :ChatTurn ; + rdfs:range xsd:string ; + rdfs:label "Question" ; + rdfs:comment "What the reader asked the assistant in this turn" ; + rdfs:isDefinedBy : . + +:answer a owl:DatatypeProperty ; + rdfs:domain :ChatTurn ; + rdfs:range xsd:string ; + rdfs:label "Answer" ; + rdfs:comment "The assistant's reading, in words, of what the turn's plan did" ; + rdfs:isDefinedBy : . + +:plan a owl:DatatypeProperty ; + rdfs:domain :ChatTurn ; + rdfs:range rdf:XMLLiteral ; + rdfs:label "Plan" ; + rdfs:comment "The Web-Algebra plan the question became: the wa:plan envelope with its summary and operation" ; + rdfs:isDefinedBy : . + +:execution a owl:DatatypeProperty ; + rdfs:domain :ChatTurn ; + rdfs:range rdf:XMLLiteral ; + rdfs:label "Execution" ; + rdfs:comment "What running the plan reported: the wa:execution with its steps, the documents written and a capped snapshot of the result" ; + rdfs:isDefinedBy : . + +:outcome a owl:DatatypeProperty ; + rdfs:domain :ChatTurn ; + rdfs:range xsd:string ; + rdfs:label "Outcome" ; + rdfs:comment "How the turn went, in one line: rows or resources returned, documents written, no results, or the failure's message" ; + rdfs:isDefinedBy : . + # CLASSES # constructor @@ -343,6 +378,18 @@ rdfs:label "View constructor" ; rdfs:isDefinedBy : . +# chat + +:Chat a rdfs:Class, owl:Class ; + rdfs:label "Assistant" ; + rdfs:comment "A conversation with the assistant, placed in a document by an object block. Its turns are its rdf:_N members, in the order they were asked" ; + rdfs:isDefinedBy : . + +:ChatTurn a rdfs:Class, owl:Class ; + rdfs:label "Assistant turn" ; + rdfs:comment "One question to the assistant and what came of it" ; + rdfs:isDefinedBy : . + # exception :ResourceExistsException a rdfs:Class, owl:Class ; diff --git a/src/main/resources/com/atomgraph/linkeddatahub/lds.ttl b/src/main/resources/com/atomgraph/linkeddatahub/lds.ttl index c0a327498..868ac7ad3 100644 --- a/src/main/resources/com/atomgraph/linkeddatahub/lds.ttl +++ b/src/main/resources/com/atomgraph/linkeddatahub/lds.ttl @@ -17,7 +17,7 @@ @prefix doap: . : a owl:Ontology ; - owl:imports ldh:, sd:, sp:, spin: ; + owl:imports ldh:, sd:, , ; rdfs:label "LinkedDataHub dataspace ontology" ; rdfs:comment "Ontology of LinkedDataHub dataspaces" ; owl:versionInfo "2.0.0" . diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 252bc4e74..e12941edd 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -361,12 +361,19 @@ support@atomgraph.com]]> com.atomgraph.linkeddatahub.listener.ClientStylesheetListener + + md + text/markdown + default /static/* /robots.txt /favicon.ico /sitemap.xml + + /AGENTS.md com.atomgraph.linkeddatahub.Application diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/app.css b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/app.css index bd2a40125..fb41f476c 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/app.css +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/app.css @@ -1488,6 +1488,13 @@ html[data-tweak-block-edges="flow"] .ldh-block:has(.ldh-edit-form) { display:contents, so it generates no box for margin-inline-start to hit. */ margin: 0; } +/* A list nested in a value cell — a blank node described in place — is its own query container, + like a nested .ldh-block: otherwise its groups query the enclosing block, which stays wide while + each level spends 264px of fixed track, and the narrow-container collapse below never fires. + Measured on schema.org JSON-LD three blank nodes deep: an 84px value column inside a 1232px block. + The full flex-basis is what gives it a width: the value cell is a wrapping flex row, which sizes + an item by its content, and inline-size containment makes that content 0. */ +.ldh-prop-row .ldh-prop-form { container-type: inline-size; flex: 1 1 100%; } /* One predicate (with N values) lives in a .ldh-prop-group grid. The label cell spans N rows; each value's .ldh-prop-row uses display:contents so its @@ -2154,6 +2161,8 @@ html[data-tweak-block-edges="flow"] .ldh-block:has(.ldh-edit-form) { .ldh-view-toolbar.is-collapsed, .ldh-pivot-bar.is-collapsed, .chart-controls.is-collapsed { display: none; } +/* the chart's Save stores what its controls picked, so it goes with them */ +.chart-controls.is-collapsed ~ .ldh-block-foot { display: none; } .ldh-grid-block .card { position: relative; display: flex; flex-direction: column; align-items: stretch; @@ -4599,3 +4608,87 @@ html[data-tweak-block-edges="flow"] .ldh-block:has(.ldh-edit-form) { box-shadow: inset 2px 0 0 var(--fg-selected); } .sb-other > li.is-active > * { color: var(--fg-selected); font-weight: var(--fw-medium); } + +/* ========================================================================= + The create bar and the assistant (Assistant.jsx). + + The create bar is the strip at the foot of the content body that holds the + document's Create button and, for a reader who can be acted for, the + Assistant button. The host decides where the bar parks (LinkedDataHub pins + it sticky at the column's foot); this is what it looks like. + + A conversation is a BLOCK - a chat, placed in the document like a view or a + chart - headed by THE block header, so a page holds as many as it has chat + blocks, and the Assistant button adds another before the bar. Its body is the + log, then its own composer as the block's footer: two checkboxes over the + question and the send button. A turn is the reader's question; a plan card is + a nested block holding the plan's summary, its operations as step rows + (planned / running / done / failed, each a disclosure folding out its XML + and, when it failed, the message), and what came of it: the documents + written, the rows returned. + ========================================================================= */ +.ldh-create-dock { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--sp-2); padding: var(--sp-2) var(--sp-6); background: var(--bg-card); border-top: 1px solid var(--border-default); } +.ldh-chat > form.ldh-chat-composer { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-top: 1px solid var(--border-default); } +.ldh-chat-composer-row { display: flex; align-items: flex-end; gap: var(--sp-2); } +.ldh-chat-composer .ac-field { flex: 1; } +.ldh-chat-options { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-4); } +.ldh-chat-composer .ac-choice { font-size: var(--fs-sm); color: var(--fg-muted); } +.ldh-chat-composer textarea { resize: none; } + +/* an empty log takes no room: a new chat block is its header and its composer */ +.ldh-chat-log:empty { display: none; } +.ldh-chat-log { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3); } +/* turns: the reader's message hugs the sender's edge, the assistant's cards sit flush left */ +.ldh-chat-turn { align-self: flex-end; max-width: 85%; margin: 0; padding: var(--sp-2) var(--sp-3); background: var(--bg-accent-quiet); color: var(--fg-1); border-radius: var(--r-md) var(--r-md) var(--r-xs) var(--r-md); font-size: var(--fs-sm); line-height: var(--lh-base); } +/* the plan card rides the nested-block well; chat scopes its rhythm */ +.ldh-chat-plan { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); font-size: var(--fs-sm); } +.ldh-chat-plan > p { margin: 0; line-height: var(--lh-base); color: var(--fg-2); } +.ldh-chat-plan-meta { font-family: var(--font-mono); font-size: 10px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--fg-muted); } +.ldh-chat-plan .ac-codefield { margin: var(--sp-2) 0 var(--sp-1); max-height: 210px; overflow: auto; } +/* the field is the kit's flex row; the pre stands where its .ac-cf-area would and fills the row like it, else it is only as wide as its longest line */ +.ldh-chat-plan .ac-codefield pre { flex: 1; min-width: 0; margin: 0; padding: var(--sp-2) var(--sp-3); font-family: var(--font-mono); font-size: 11px; line-height: var(--lh-snug); color: var(--fg-2); white-space: pre; } +.ldh-chat-plan-actions { display: flex; gap: var(--sp-2); } +/* the answer: the card's first line once there is one, read off the rows below it */ +.ldh-chat-answer { margin: 0; font-size: var(--fs-md); line-height: var(--lh-base); color: var(--fg-1); white-space: pre-line; } +/* the trace: the steps under a disclosure whose summary is their count and the plan's time - open while the plan is + being read or run, folded once the answer has been written. No marker; the meta line is the control */ +details.ldh-chat-trace > summary { font-family: var(--font-mono); font-size: 10px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--fg-muted); cursor: pointer; list-style: none; } +/* the summary is a control and looks like one: a chevron that turns when open, and the outcome's glyph */ +details.ldh-chat-trace > summary { display: inline-flex; align-items: center; gap: var(--sp-1); padding: 2px var(--sp-2) 2px 2px; border-radius: var(--r-sm); } +details.ldh-chat-trace > summary:hover { background: var(--bg-hover); } +details.ldh-chat-trace > summary > .chev { transition: transform var(--dur-fast, 120ms) ease; } +details.ldh-chat-trace[open] > summary > .chev { transform: rotate(90deg); } +details.ldh-chat-trace > summary > .st.is-done { color: var(--success-500); } +details.ldh-chat-trace > summary > .st.is-failed { color: var(--danger-500); } +details.ldh-chat-trace > summary::-webkit-details-marker { display: none; } +details.ldh-chat-trace > summary:hover { color: var(--fg-2); } +details.ldh-chat-trace > ul { margin-top: var(--sp-2); } +/* The steps: one row per operation, there from the moment the plan is shown and changing state as the + executor reports - planned, running, done, failed. Each row is a disclosure whose summary is the row + and whose body is the operation's own XML (the first row's is the whole plan) and, when it failed, + the message; no marker, the row itself is the control. The tint is the status colour set the + alerts use (success-50 / danger-50), so a row that went green or red reads like the alert it replaces */ +.ldh-chat-steps, .ldh-chat-steps ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +/* the rows nest as the operations do: a step inside another's argument sits one level in, under a hairline */ +.ldh-chat-steps ul { margin: 2px 0 0 calc(12px + var(--sp-1)); padding-left: var(--sp-2); border-left: 1px solid var(--border-default); } +details.ldh-chat-step { display: block; } +details.ldh-chat-step > summary { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: var(--sp-2); padding: var(--sp-1) var(--sp-2); border-radius: var(--r-sm); font-size: var(--fs-sm); color: var(--fg-1); cursor: pointer; list-style: none; } +details.ldh-chat-step > summary::-webkit-details-marker { display: none; } +details.ldh-chat-step > summary:hover { background: var(--bg-hover); } +.ldh-chat-step .st { display: inline-flex; color: var(--fg-hint); } +.ldh-chat-step .op { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ldh-chat-step.is-running > summary { background: var(--bg-accent-quiet); } +.ldh-chat-step.is-running .st { justify-self: center; } +.ldh-chat-step.is-done > summary { background: var(--success-50); } +.ldh-chat-step.is-done .st { color: var(--success-500); } +.ldh-chat-step.is-failed > summary { background: var(--danger-50); } +.ldh-chat-step.is-failed .st { color: var(--danger-500); } +.ldh-chat-step.kd-write .st { color: var(--warning-500); } +.ldh-chat-step.kd-destructive .st { color: var(--danger-500); } +.ldh-chat-step-message { margin: var(--sp-2) var(--sp-2) 0; font-size: var(--fs-sm); line-height: var(--lh-base); color: var(--fg-2); } +/* changed-document links: one row per affected graph, mono URI leaf */ +.ldh-chat-docs .op { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; } +/* a result sits in a well of its own under the card - depth 2 - headed like a block, in the block's width; its canvases + are sized as a view's are */ +.ldh-chat-result-block { margin-top: var(--sp-2); } +.ldh-chat-result-block > .ldh-nblock-body { padding: var(--sp-2) var(--sp-3) var(--sp-3); max-width: 100%; overflow-x: auto; } diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css index 5070c76d3..d5da76f56 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css @@ -135,7 +135,7 @@ object { display: block; margin: auto; max-height: 400px; overflow: hidden; widt .graph-3d-show-panel label.sub-option { margin-left: 16px; } .graph-3d-zoom { position: absolute; top: 10px; left: 10px; z-index: 10; } .graph-3d-fullscreen { position: absolute; bottom: 10px; right: 10px; z-index: 10; } -.graph-3d-canvas.graph-3d-maximized { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1000; } +.graph-3d-canvas.graph-3d-maximized { position: fixed; inset: 0; width: auto; height: auto; z-index: 1000; } /* OpenLayers specific */ /* the marker info window borrows the modal's head/body anatomy but its host is ol.Overlay's own wrapper, not a dialog - so the panel treatment lands here: the overlay IS the card, which is why @@ -268,7 +268,7 @@ circle { cursor: move; } /* ---------- sticky chrome: header on top, action bar (legacy --action-bar-top) right under it. Bar heights are tokens that size the bars, not mirrors of their content, so stacked sticky offsets (.editor-bar) can compose them without drifting ---------- */ -:root { --ldh-header-height: 56px; --ldh-tabbar-height: 40px; --ldh-actionbar-height: 61px; --action-bar-top: var(--ldh-header-height); --ldh-content-w: 1280px; } +:root { --ldh-header-height: 56px; --ldh-tabbar-height: 40px; --ldh-actionbar-height: 61px; --action-bar-top: var(--ldh-header-height); --ldh-content-w: 1280px; --ldh-pane-w: 100vw; /* the width the dataspace's panes span: the viewport, less whatever stands beside them */ } /* image caps for RDF-pointed depictions: the pixel size is whatever the dataset points at, so the SURFACE picks the height. These once lived in the pre-refresh vendored app.css; a re-vendor lost the definitions while the var() uses stayed, silently voiding every declaration that read them @@ -583,21 +583,21 @@ button.tree-link { border: 0; background: transparent; width: 100%; font: inheri modes' Create picker) sits in a full-bleed header-like row. Sticky, not fixed: it rides the viewport bottom while the content scrolls, but parks at its flow position at the end of the content - stacked on the footer, never over it. The negative side margins bleed - the row out of the centered content column to the viewport edges; the right padding brings + the row out of the centered content column to the panes' edges; the right padding brings the buttons back to the content column's right edge, flush with the blocks. The margin bleed - and the padding must share the 100vw base: each is half a classic scrollbar off the layout - width, in the same direction, so the errors cancel and the buttons land exactly on the column - edge - rebasing one without the other breaks the alignment ---------- */ + and the padding must share the --ldh-pane-w base: each is half a classic scrollbar off the + layout width, in the same direction, so the errors cancel and the buttons land exactly on the + column edge - rebasing one without the other breaks the alignment. The base is the panes' + width rather than the viewport's because the assistant drawer stands beside the panes and + narrows it (the chat section below) ---------- */ html { overflow-x: clip; } /* the 50vw bleed overshoots by half a classic scrollbar; clip never creates a scroll container */ +/* the bar's look is the app kit's (app.css, .ldh-create-dock); this is where the page parks it */ .ldh-create-dock { position: sticky; bottom: 0; - display: flex; align-items: center; justify-content: flex-end; gap: var(--sp-2); z-index: 24; /* above content, below popovers (40) and modal chrome */ - margin: auto calc(50% - 50vw) 0; /* margin-top: auto parks it at the bottom of the stretched column */ - padding: var(--sp-2) calc((100vw - min(var(--ldh-content-w), 100vw)) / 2 + var(--sp-6)) var(--sp-2) var(--sp-6); - background: var(--bg-card); - border-top: 1px solid var(--border-default); + margin: auto calc(50% - var(--ldh-pane-w) / 2) 0; /* margin-top: auto parks it at the bottom of the stretched column */ + padding-right: calc((var(--ldh-pane-w) - min(var(--ldh-content-w), var(--ldh-pane-w))) / 2 + var(--sp-6)); } /* the modal dim overlay is pointer-inert, so hide the dock while a modal is open */ body:has(.ac-backdrop.modal) .ldh-create-dock { display: none; } @@ -973,3 +973,5 @@ div.ldh-failure > .ac-alert { margin: 0; } .rdfa-editor-content th { background: var(--bg-recess); } .rdfa-editor-content caption { color: var(--fg-muted); } .rdfa-editor-content .rdfa-editor-island { border-color: var(--border-default); background: var(--bg-recess); } + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl index a9fd3f2a1..6ce8b20a2 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl @@ -87,6 +87,7 @@ extension-element-prefixes="ixsl" + @@ -101,13 +102,13 @@ extension-element-prefixes="ixsl" - - - + + + - + @@ -116,7 +117,7 @@ extension-element-prefixes="ixsl" - + PREFIX skos: @@ -148,7 +149,7 @@ WHERE ORDER BY ?label ]]> - + - + @@ -191,6 +192,9 @@ WHERE + + + + + @@ -321,10 +329,10 @@ WHERE - - - - + + + + @@ -453,10 +461,24 @@ WHERE + + + + + + + + + + + + - + @@ -473,55 +495,35 @@ WHERE a second map in every other tab's map canvas (measured 2026-09-28 with three dataspace tabs) --> - - - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + @@ -781,6 +783,7 @@ WHERE + @@ -846,7 +849,7 @@ WHERE - + @@ -887,7 +890,7 @@ WHERE - + @@ -932,9 +935,13 @@ WHERE - + + + @@ -983,6 +990,7 @@ WHERE + @@ -995,6 +1003,8 @@ WHERE + + @@ -1018,7 +1028,8 @@ WHERE 'server-rendered': $server-rendered, 'endpoint': sd:endpoint() }, - if (exists($diff-version)) then map{ 'diff-request': map{ 'method': 'GET', 'href': ldh:href($doc-uri, map{ 'version': $diff-version }, ()), 'headers': map{ 'Accept': 'application/rdf+xml' } } } else map{} + if (exists($diff-version)) then map{ 'diff-request': map{ 'method': 'GET', 'href': ldh:href($doc-uri, map{ 'version': $diff-version }, ()), 'headers': map{ 'Accept': 'application/rdf+xml' } } } else map{}, + if (exists($known-blocks)) then map{ 'known-blocks': $known-blocks } else map{} ))"/> - +

    @@ -1645,9 +1656,9 @@ WHERE - + - + Importing RDF file. Name: '' Media type: '' diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block.xsl index 8f2205109..9f0e1e75e 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block.xsl @@ -56,7 +56,7 @@ exclude-result-prefixes="#all" - + @@ -80,7 +80,7 @@ exclude-result-prefixes="#all" - + PREFIX rdf: @@ -132,7 +132,7 @@ exclude-result-prefixes="#all" (property range for forward views, domain for inverse ones) and the view's ldh:showWhenEmpty. No container: where a view creates into is a property of its results rather than of its declaration, so it is determined per view by ldh:ViewContainer in block/view.xsl. --> - + PREFIX ldh: @@ -322,7 +322,7 @@ exclude-result-prefixes="#all" - + @@ -513,7 +513,7 @@ exclude-result-prefixes="#all" - + - + @@ -1174,10 +1174,10 @@ exclude-result-prefixes="#all" then distinct-values($response?body/rdf:RDF/rdf:Description/*/@rdf:resource[not(key('resources', .))]) else ()"/> - + - + ldh:load-object-metadata @@ -1261,7 +1261,7 @@ exclude-result-prefixes="#all" return if ($response?status = 200 and $response?media-type = 'application/rdf+xml') then distinct-values($response?body/rdf:RDF/rdf:Description/*/concat(namespace-uri(), local-name())) else ()"/> - + ldh:load-property-metadata diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl index 3c40190cc..5fe0b19e1 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl @@ -225,7 +225,8 @@ exclude-result-prefixes="#all" - + + @@ -406,13 +407,14 @@ exclude-result-prefixes="#all" - - - - - + + + + + - + @@ -423,101 +425,93 @@ exclude-result-prefixes="#all" - - - - - + + + Chart control values missing for content '' - - - - - - - - - - + + + + + + + + + - + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - Chart control values missing for content '' - - - - + + + - + - - - - - - - - - - - - - - - - - - - + + + + + + - - Chart control values missing for content '' - + + + + + + + + - - + + - - - - - - - + + + - + + + + + + + + - - - - - - - - - - + + + + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/object.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/object.xsl index 16a67382c..46bb84603 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/object.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/object.xsl @@ -198,10 +198,10 @@ exclude-result-prefixes="#all" - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/query.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/query.xsl index bc9d66609..296a4cae9 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/query.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/query.xsl @@ -737,18 +737,15 @@ exclude-result-prefixes="#all" - + - - - - - - - - - - + + + + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/view.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/view.xsl index cb169ce67..d2b80165b 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/view.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/view.xsl @@ -43,7 +43,7 @@ exclude-result-prefixes="#all" - + @@ -378,7 +378,7 @@ exclude-result-prefixes="#all" - + - + - + @@ -880,7 +880,7 @@ exclude-result-prefixes="#all" - + @@ -1038,131 +1038,45 @@ exclude-result-prefixes="#all" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + +
    + + - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - + - + @@ -1521,7 +1435,7 @@ exclude-result-prefixes="#all" - + @@ -1792,6 +1706,11 @@ exclude-result-prefixes="#all" + + + + + @@ -1905,7 +1824,7 @@ exclude-result-prefixes="#all" - + - - + + @@ -2004,8 +1923,8 @@ exclude-result-prefixes="#all" - - + + @@ -2047,8 +1966,8 @@ exclude-result-prefixes="#all" - - + + @@ -2161,8 +2080,8 @@ exclude-result-prefixes="#all" - - + + @@ -2209,8 +2128,8 @@ exclude-result-prefixes="#all" - - + + @@ -2260,8 +2179,8 @@ exclude-result-prefixes="#all" - - + + @@ -2317,8 +2236,8 @@ exclude-result-prefixes="#all" - - + + @@ -2373,7 +2292,7 @@ exclude-result-prefixes="#all" - + @@ -2458,7 +2377,7 @@ exclude-result-prefixes="#all" - + - - + + @@ -2666,8 +2585,8 @@ exclude-result-prefixes="#all" - - + + @@ -2808,8 +2727,8 @@ exclude-result-prefixes="#all" - - + + @@ -2820,7 +2739,7 @@ exclude-result-prefixes="#all" - + @@ -2910,7 +2829,7 @@ exclude-result-prefixes="#all" - + @@ -2940,7 +2859,7 @@ exclude-result-prefixes="#all" - + @@ -3219,7 +3138,7 @@ exclude-result-prefixes="#all" - + - + - + - + @@ -3844,8 +3763,8 @@ exclude-result-prefixes="#all" - - + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/chat.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/chat.xsl new file mode 100644 index 000000000..7306ee9a5 --- /dev/null +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/chat.xsl @@ -0,0 +1,1845 @@ + + + + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + +
    + +
    + + + + + +
    +
    + + + + + +
    +
    + + +
    +
    +
    +
    +