diff --git a/.gitignore b/.gitignore
index b6d335acaa..0a0a72c505 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,4 @@
/http-tests/out
/fuseki
.claude/scheduled_tasks.lock
+/cli/target/
diff --git a/Dockerfile b/Dockerfile
index a0f479ddf1..afcdb0e8d2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -38,8 +38,6 @@ ENV SOURCE_COMMIT=$SOURCE_COMMIT
WORKDIR $CATALINA_HOME
-ENV CACHE_MODEL_LOADS=true
-
ENV STYLESHEET=static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl
ENV CACHE_STYLESHEET=true
diff --git a/cli/README.md b/cli/README.md
new file mode 100644
index 0000000000..b376435346
--- /dev/null
+++ b/cli/README.md
@@ -0,0 +1,128 @@
+# LinkedDataHub CLI
+
+`ldh` is a command line interface for the [LinkedDataHub](https://github.com/AtomGraph/LinkedDataHub) HTTP API.
+It mirrors the shell scripts in [`bin/`](../bin) one command per script, with the same option names,
+implemented in Java on top of AtomGraph [Core](https://github.com/AtomGraph/Core)'s `GraphStoreClient`
+(picocli + Apache Jena). It replaces the scripts' external dependencies (`curl`, `turtle`, `python`,
+`uuidgen`, `shasum`) with a single executable jar.
+
+## Build
+
+Requires Java 21 and Maven:
+
+```bash
+cd cli
+mvn package
+```
+
+This produces the self-contained `target/ldh.jar`. The `cli/bin/ldh` launcher runs it:
+
+```bash
+export PATH="$PATH:$(pwd)/bin"
+ldh --help
+```
+
+## Authentication
+
+Commands authenticate with a WebID client certificate from a **PKCS12 (.p12) keystore** — the format
+produced by `bin/webid-keygen.sh`:
+
+```bash
+ldh get --accept text/turtle \
+ -f ssl/owner/keystore.p12 -p "$OWNER_CERT_PWD" \
+ https://localhost:4443/
+```
+
+Server certificates are not validated (equivalent of `curl -k`), matching the shell scripts'
+behavior against self-signed development instances.
+
+### Environment variable defaults
+
+Repeated options can be set once via environment variables:
+
+| Variable | Option |
+|---|---|
+| `LDH_CERT_FILE` | `-f`, `--cert-file` |
+| `LDH_CERT_PASSWORD` | `-p`, `--cert-password` |
+| `LDH_BASE` | `-b`, `--base` |
+| `LDH_PROXY` | `--proxy` |
+
+```bash
+export LDH_CERT_FILE=ssl/owner/keystore.p12 LDH_CERT_PASSWORD=... LDH_BASE=https://localhost:4443/
+
+ldh create-container --parent "$LDH_BASE" --title "Some" --slug some
+ldh create-item --container https://localhost:4443/some/ --title "My item" --slug my-item
+```
+
+## Conventions
+
+- Commands that create or append to a document print its URL as the only line on stdout, so shell
+ pipelines keep working: `item=$(ldh create-item ...)`. `add-file` prints the content-addressed
+ upload URI (`{base}uploads/{sha1}`). All diagnostics go to stderr.
+- Exit codes: `0` success, `1` HTTP error status or runtime failure (message on stderr, stack trace
+ with `--verbose`), `2` usage error.
+- `--proxy` rewrites the request URI's origin to the proxy's origin, like the scripts do; printed
+ URLs keep the logical origin.
+- `post`/`put` read RDF from stdin and resolve relative URIs against the target URI (the scripts'
+ `turtle --base` piping); `patch` reads a SPARQL 1.1 update from stdin, validates it and sends it
+ verbatim.
+
+Shell completion: `source <(ldh generate-completion)` (bash/zsh).
+
+## Script → command migration
+
+| Script | Command |
+|---|---|
+| `get.sh` | `ldh get` |
+| `post.sh` | `ldh post` |
+| `put.sh` | `ldh put` |
+| `patch.sh` | `ldh patch` |
+| `delete.sh` | `ldh delete` |
+| `create-item.sh` | `ldh create-item` |
+| `create-container.sh` | `ldh create-container` |
+| `add-view.sh` | `ldh add-view` |
+| `add-construct.sh` | `ldh add-construct` |
+| `add-select.sh` | `ldh add-select` |
+| `add-result-set-chart.sh` | `ldh add-result-set-chart` |
+| `add-file.sh` | `ldh add-file` |
+| `add-generic-service.sh` | `ldh add-generic-service` |
+| `admin/clear-ontology.sh` | `ldh admin clear-ontology` |
+| `admin/add-ontology-import.sh` | `ldh admin add-ontology-import` |
+| `admin/ontologies/create-ontology.sh` | `ldh admin ontologies create-ontology` |
+| `admin/ontologies/import-ontology.sh` | `ldh admin ontologies import-ontology` |
+| `admin/ontologies/add-class.sh` | `ldh admin ontologies add-class` |
+| `admin/ontologies/add-constructor.sh` | `ldh admin ontologies add-constructor` |
+| `admin/ontologies/add-select.sh` | `ldh admin ontologies add-select` |
+| `admin/ontologies/add-property-constraint.sh` | `ldh admin ontologies add-property-constraint` |
+| `admin/ontologies/add-restriction.sh` | `ldh admin ontologies add-restriction` |
+| `admin/acl/create-group.sh` | `ldh admin acl create-group` |
+| `admin/acl/create-authorization.sh` | `ldh admin acl create-authorization` |
+| `admin/acl/add-agent-to-group.sh` | `ldh admin acl add-agent-to-group` |
+| `admin/acl/make-public.sh` | `ldh admin acl make-public` |
+| `admin/packages/install-package.sh` | `ldh admin packages install-package` |
+| `admin/packages/uninstall-package.sh` | `ldh admin packages uninstall-package` |
+| `content/add-object-block.sh` | `ldh content add-object-block` |
+| `content/add-xhtml-block.sh` | `ldh content add-xhtml-block` |
+| `content/remove-block.sh` | `ldh content remove-block` |
+| `imports/add-csv-import.sh` | `ldh imports add-csv-import` |
+| `imports/add-rdf-import.sh` | `ldh imports add-rdf-import` |
+| `imports/import-csv.sh` | `ldh imports import-csv` |
+| `imports/import-rdf.sh` | `ldh imports import-rdf` |
+
+Local certificate tooling (`webid-keygen.sh`, `webid-keygen-pem.sh`, `webid-uri.sh`,
+`webid-modulus.sh`, `server-cert-gen.sh`) and the experimental `sitemap/` generator remain
+shell scripts.
+
+### Differences from the scripts
+
+- `-f/--cert-pem-file` is now `-f/--cert-file` and takes the `.p12` keystore directly — no
+ PEM conversion needed.
+- `create-group` writes the `--name` value into `foaf:name`/`dct:title` (the script wrote an
+ unset variable, producing empty literals).
+- `add-generic-service` drops the documented-but-unparsed `--slug` option.
+- `add-csv-import`/`import-csv` default `--delimiter` to `,` (the script required it despite
+ documenting a default).
+- `install-package`/`uninstall-package` print nothing on success instead of the raw HTTP status
+ code; use the exit code.
+- `import-csv`/`import-rdf` run their steps in-process instead of spawning subscripts, and pass
+ `--description` through to the import metadata.
diff --git a/cli/bin/ldh b/cli/bin/ldh
new file mode 100755
index 0000000000..93c9da9875
--- /dev/null
+++ b/cli/bin/ldh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+# Launcher for the LinkedDataHub CLI.
+# Uses the jar built by `mvn package` unless LDH_JAR points elsewhere.
+
+dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+exec java -jar "${LDH_JAR:-$dir/../target/ldh.jar}" "$@"
diff --git a/cli/pom.xml b/cli/pom.xml
new file mode 100644
index 0000000000..bb7dfa7475
--- /dev/null
+++ b/cli/pom.xml
@@ -0,0 +1,162 @@
+
+
+ 4.0.0
+
+ com.atomgraph
+ linkeddatahub-cli
+ 1.0.0-SNAPSHOT
+ jar
+
+ LinkedDataHub CLI
+ Command line interface for the LinkedDataHub HTTP API
+ https://github.com/AtomGraph/LinkedDataHub
+
+
+ UTF-8
+ 21
+ 3.1.11
+
+
+
+
+ info.picocli
+ picocli
+ 4.7.7
+
+
+ com.atomgraph
+ core
+ 5.0.2
+
+
+
+ org.glassfish.jersey.containers
+ jersey-container-servlet
+
+
+
+
+ org.apache.jena
+ jena-arq
+ 6.1.0
+
+
+
+ org.apache.httpcomponents
+ httpcore
+
+
+ org.apache.httpcomponents
+ httpclient-cache
+
+
+ org.apache.httpcomponents
+ httpclient
+
+
+ org.apache.httpcomponents
+ httpclient-osgi
+
+
+ org.apache.httpcomponents
+ httpcore-osgi
+
+
+
+
+ org.glassfish.jersey.connectors
+ jersey-apache-connector
+ ${jersey.version}
+
+
+ org.glassfish.jersey.media
+ jersey-media-multipart
+ ${jersey.version}
+
+
+
+ jakarta.activation
+ jakarta.activation-api
+ 2.1.3
+ runtime
+
+
+ org.slf4j
+ slf4j-nop
+ 2.0.17
+ runtime
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.12.2
+ test
+
+
+
+
+ ldh
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.14.1
+
+ 21
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.2
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.6.0
+
+
+ package
+
+ shade
+
+
+ false
+
+
+ com.atomgraph.linkeddatahub.cli.LDH
+
+ true
+
+
+
+
+
+
+ META-INF/hk2-locator/default
+
+
+
+ false
+
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+ module-info.class
+ META-INF/versions/*/module-info.class
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/BaseCommand.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/BaseCommand.java
new file mode 100644
index 0000000000..986b32bf3d
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/BaseCommand.java
@@ -0,0 +1,209 @@
+/*
+ * 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.cli;
+
+import com.atomgraph.core.MediaTypes;
+import com.atomgraph.linkeddatahub.cli.http.ClientFactory;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.http.LDHClient;
+import com.atomgraph.linkeddatahub.cli.mixin.CertAuthMixin;
+import com.atomgraph.linkeddatahub.cli.mixin.ProxyMixin;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.MediaType;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.util.concurrent.Callable;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.riot.Lang;
+import org.apache.jena.riot.RDFLanguages;
+import org.apache.jena.riot.RDFParser;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Model.CommandSpec;
+import picocli.CommandLine.ParameterException;
+import picocli.CommandLine.Spec;
+
+/**
+ * Base class for all commands: WebID certificate authentication, proxy handling
+ * and shared RDF/HTTP helpers.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public abstract class BaseCommand implements Callable
+{
+
+ /** Accepted response media type used by the scripts (Accept: text/turtle) */
+ protected static final MediaType[] ACCEPT_TURTLE = { com.atomgraph.core.MediaType.TEXT_TURTLE_TYPE };
+ /** Accepted response media type for content block sequence scanning */
+ protected static final MediaType[] ACCEPT_NTRIPLES = { com.atomgraph.core.MediaType.APPLICATION_NTRIPLES_TYPE };
+ /** Turtle request body media type */
+ protected static final MediaType TEXT_TURTLE_TYPE = com.atomgraph.core.MediaType.TEXT_TURTLE_TYPE;
+
+ @Spec
+ private CommandSpec spec;
+
+ @Mixin
+ private CertAuthMixin certAuth;
+
+ @Mixin
+ private ProxyMixin proxyMixin;
+
+ private LDHClient client;
+
+ /**
+ * Returns the lazily-built authenticated client.
+ *
+ * @return client instance
+ */
+ protected LDHClient getClient()
+ {
+ if (client == null)
+ {
+ getCertAuth().validate(getSpec());
+ client = new LDHClient(ClientFactory.createClient(getCertAuth().getCertFile(), getCertAuth().getCertPassword()),
+ new MediaTypes(), getEffectiveProxy());
+ }
+
+ return client;
+ }
+
+ /**
+ * Returns the proxy URI applied to requests. Commands that target the admin application
+ * override this to convert the proxy to the admin subdomain.
+ *
+ * @return proxy URI or null
+ */
+ protected URI getEffectiveProxy()
+ {
+ return getProxyMixin().getProxy();
+ }
+
+ /**
+ * POSTs a model to a document, failing on error status.
+ *
+ * @param client client instance
+ * @param target document URI
+ * @param model appended model
+ */
+ protected static void post(LDHClient client, URI target, Model model)
+ {
+ HttpException.check(target, client.post(target, Entity.entity(model, TEXT_TURTLE_TYPE), ACCEPT_TURTLE)).close();
+ }
+
+ /**
+ * PUTs a model as a document, failing on error status.
+ *
+ * @param client client instance
+ * @param target document URI
+ * @param model document model
+ */
+ protected static void put(LDHClient client, URI target, Model model)
+ {
+ HttpException.check(target, client.put(target, Entity.entity(model, TEXT_TURTLE_TYPE), ACCEPT_TURTLE)).close();
+ }
+
+ /**
+ * Returns the subject resource for an appended description: the --uri value
+ * resolved against the target document URI, or a fresh blank node when not given.
+ *
+ * @param model model to create the resource in
+ * @param target target document URI
+ * @param uri --uri option value (absolute or relative, can be null)
+ * @return subject resource
+ */
+ protected static Resource createSubject(Model model, URI target, String uri)
+ {
+ return uri != null ? model.createResource(target.resolve(uri).toString()) : model.createResource();
+ }
+
+ /**
+ * Parses an RDF stream into a model, resolving relative URIs against the base URI
+ * (the equivalent of the scripts' turtle --base piping).
+ *
+ * @param contentType RDF media type
+ * @param base base URI
+ * @param in RDF input stream
+ * @return parsed model
+ */
+ protected Model readModel(String contentType, URI base, InputStream in)
+ {
+ Lang lang = RDFLanguages.contentTypeToLang(contentType);
+ if (lang == null) throw new ParameterException(getSpec().commandLine(), "Unsupported RDF media type: '" + contentType + "'");
+
+ Model model = ModelFactory.createDefaultModel();
+ RDFParser.create().source(in).lang(lang).base(base.toString()).parse(model);
+ return model;
+ }
+
+ /**
+ * Streams a response body to standard output unmodified.
+ *
+ * @param response response with the body to stream
+ * @throws IOException stream error
+ */
+ protected static void printBody(jakarta.ws.rs.core.Response response) throws IOException
+ {
+ try (response; InputStream is = response.readEntity(InputStream.class))
+ {
+ is.transferTo(System.out);
+ System.out.flush();
+ }
+ }
+
+ /**
+ * Prints a line to standard output. Command results (created document URLs) go through here.
+ *
+ * @param value printed value
+ */
+ protected void print(Object value)
+ {
+ getSpec().commandLine().getOut().println(value);
+ }
+
+ /**
+ * Returns the command spec.
+ *
+ * @return command spec
+ */
+ protected CommandSpec getSpec()
+ {
+ return spec;
+ }
+
+ /**
+ * Returns the certificate options.
+ *
+ * @return certificate mixin
+ */
+ protected CertAuthMixin getCertAuth()
+ {
+ return certAuth;
+ }
+
+ /**
+ * Returns the proxy options.
+ *
+ * @return proxy mixin
+ */
+ protected ProxyMixin getProxyMixin()
+ {
+ return proxyMixin;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/LDH.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/LDH.java
new file mode 100644
index 0000000000..4f3842bbf7
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/LDH.java
@@ -0,0 +1,98 @@
+/*
+ * 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.cli;
+
+import com.atomgraph.linkeddatahub.cli.command.AddConstruct;
+import com.atomgraph.linkeddatahub.cli.command.AddFile;
+import com.atomgraph.linkeddatahub.cli.command.AddGenericService;
+import com.atomgraph.linkeddatahub.cli.command.AddResultSetChart;
+import com.atomgraph.linkeddatahub.cli.command.AddSelect;
+import com.atomgraph.linkeddatahub.cli.command.AddView;
+import com.atomgraph.linkeddatahub.cli.command.CreateContainer;
+import com.atomgraph.linkeddatahub.cli.command.CreateItem;
+import com.atomgraph.linkeddatahub.cli.command.Delete;
+import com.atomgraph.linkeddatahub.cli.command.Get;
+import com.atomgraph.linkeddatahub.cli.command.Patch;
+import com.atomgraph.linkeddatahub.cli.command.Post;
+import com.atomgraph.linkeddatahub.cli.command.Put;
+import com.atomgraph.linkeddatahub.cli.command.admin.Admin;
+import com.atomgraph.linkeddatahub.cli.command.content.Content;
+import com.atomgraph.linkeddatahub.cli.command.imports.Imports;
+import org.apache.jena.sys.JenaSystem;
+import picocli.AutoComplete;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.ScopeType;
+
+/**
+ * Root command of the LinkedDataHub CLI.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "ldh",
+ mixinStandardHelpOptions = true,
+ version = "ldh 1.0.0-SNAPSHOT",
+ description = "Command line interface for the LinkedDataHub HTTP API.",
+ subcommands = {
+ Get.class, Post.class, Put.class, Patch.class, Delete.class,
+ CreateItem.class, CreateContainer.class,
+ AddView.class, AddConstruct.class, AddSelect.class, AddResultSetChart.class, AddFile.class, AddGenericService.class,
+ Admin.class, Content.class, Imports.class,
+ AutoComplete.GenerateCompletion.class
+ })
+public class LDH
+{
+
+ @Option(names = "--verbose", scope = ScopeType.INHERIT, description = "Print stack traces of errors")
+ boolean verbose;
+
+ /**
+ * CLI entry point.
+ *
+ * @param args command line arguments
+ */
+ public static void main(String[] args)
+ {
+ JenaSystem.init();
+
+ CommandLine cmd = new CommandLine(new LDH());
+ cmd.setExecutionExceptionHandler(LDH::handleExecutionException);
+ System.exit(cmd.execute(args));
+ }
+
+ static int handleExecutionException(Exception ex, CommandLine cmdLine, CommandLine.ParseResult parseResult)
+ {
+ boolean verbose = cmdLine.getCommandSpec().root().userObject() instanceof LDH root && root.verbose;
+
+ cmdLine.getErr().println(cmdLine.getColorScheme().errorText(messageOf(ex)));
+ if (verbose) ex.printStackTrace(cmdLine.getErr());
+
+ return CommandLine.ExitCode.SOFTWARE;
+ }
+
+ static String messageOf(Throwable ex)
+ {
+ // unwrap Jersey ProcessingException chains down to the I/O cause, e.g. "Connection refused"
+ Throwable cause = ex;
+ while (cause.getCause() != null && (cause instanceof jakarta.ws.rs.ProcessingException || cause.getMessage() == null))
+ cause = cause.getCause();
+
+ return cause.getMessage() != null ? cause.getMessage() : cause.toString();
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddConstruct.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddConstruct.java
new file mode 100644
index 0000000000..91194f7b21
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddConstruct.java
@@ -0,0 +1,120 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.LDHClient;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.LDH;
+import com.atomgraph.linkeddatahub.cli.vocab.SP;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Adds a SPARQL CONSTRUCT query to a document. Mirrors bin/add-construct.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-construct", description = "Adds a CONSTRUCT query to a document.")
+public class AddConstruct extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the query")
+ private String title;
+
+ @Option(names = "--query-file", required = true, paramLabel = "ABS_PATH", description = "Path to the file with the query string")
+ private Path queryFile;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the query (optional)")
+ private String description;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the query (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--service", paramLabel = "SERVICE_URI", description = "URI of the SPARQL service (optional)")
+ private URI service;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ core(getClient(), target, uri, title, Files.readString(queryFile), service, description);
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Appends the CONSTRUCT query description to the target document.
+ *
+ * @param client client instance
+ * @param target target document URI
+ * @param uri query URI (optional)
+ * @param title query title
+ * @param queryText query string
+ * @param service SPARQL service URI (optional)
+ * @param description query description (optional)
+ */
+ public static void core(LDHClient client, URI target, String uri, String title, String queryText, URI service, String description)
+ {
+ post(client, target, buildModel(target, uri, SP.Construct, title, queryText, service, description));
+ }
+
+ /**
+ * Builds a SPIN query description.
+ *
+ * @param target target document URI
+ * @param uri query URI (optional)
+ * @param queryType SPIN query class (sp:Construct or sp:Select)
+ * @param title query title
+ * @param queryText query string
+ * @param service SPARQL service URI (optional)
+ * @param description query description (optional)
+ * @return query model
+ */
+ public static Model buildModel(URI target, String uri, Resource queryType, String title, String queryText, URI service, String description)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource query = createSubject(model, target, uri).
+ addProperty(RDF.type, queryType).
+ addProperty(DCTerms.title, title).
+ addProperty(SP.text, queryText);
+ if (service != null) query.addProperty(LDH.service, model.createResource(service.toString()));
+ if (description != null) query.addProperty(DCTerms.description, description);
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddFile.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddFile.java
new file mode 100644
index 0000000000..8114dec2ab
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddFile.java
@@ -0,0 +1,141 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.http.LDHClient;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.Digests;
+import com.atomgraph.linkeddatahub.cli.vocab.NFO;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.MediaType;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import org.glassfish.jersey.media.multipart.FormDataMultiPart;
+import org.glassfish.jersey.media.multipart.file.FileDataBodyPart;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Uploads a file using the RDF/POST multipart encoding. Mirrors bin/add-file.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-file", description = "Uploads a file.")
+public class AddFile extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the file")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the file (optional)")
+ private String description;
+
+ @Option(names = "--file", required = true, paramLabel = "ABS_PATH", description = "Path to the file")
+ private Path file;
+
+ @Option(names = "--content-type", paramLabel = "MEDIA_TYPE", description = "Media type of the file (optional, auto-detected if not set)")
+ private String contentType;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ URI base = baseMixin.require(getSpec());
+
+ URI fileURI = core(getClient(), base, target, file, contentType, title, description);
+ print(fileURI);
+
+ return 0;
+ }
+
+ /**
+ * Uploads the file to the target document and returns its content-addressed upload URI.
+ * The RDF/POST field order is positional: each pu must immediately precede
+ * its ol/ou value.
+ *
+ * @param client client instance
+ * @param base application base URI
+ * @param target target document URI
+ * @param file file path
+ * @param contentType file media type (optional, auto-detected if null)
+ * @param title file title
+ * @param description file description (optional)
+ * @return upload URI derived from the SHA1 hash of the file content
+ * @throws IOException file read error
+ */
+ public static URI core(LDHClient client, URI base, URI target, Path file, String contentType, String title, String description) throws IOException
+ {
+ String fileContentType = contentType != null ? contentType : detectContentType(file);
+
+ try (FormDataMultiPart multiPart = buildMultiPart(file, fileContentType, title, description))
+ {
+ HttpException.check(target, client.post(target, Entity.entity(multiPart, multiPart.getMediaType()), ACCEPT_TURTLE)).close();
+ }
+
+ return URI.create(base.toString() + "uploads/" + Digests.sha1Hex(file));
+ }
+
+ /**
+ * Builds the RDF/POST multipart body.
+ *
+ * @param file file path
+ * @param contentType file media type
+ * @param title file title
+ * @param description file description (optional)
+ * @return multipart body
+ */
+ public static FormDataMultiPart buildMultiPart(Path file, String contentType, String title, String description)
+ {
+ FormDataMultiPart multiPart = new FormDataMultiPart();
+
+ multiPart.field("rdf", "");
+ multiPart.field("sb", "file");
+ multiPart.field("pu", NFO.fileName.getURI());
+ multiPart.bodyPart(new FileDataBodyPart("ol", file.toFile(), MediaType.valueOf(contentType)));
+ multiPart.field("pu", DCTerms.title.getURI());
+ multiPart.field("ol", title);
+ multiPart.field("pu", RDF.type.getURI());
+ multiPart.field("ou", NFO.FileDataObject.getURI());
+ if (description != null)
+ {
+ multiPart.field("pu", DCTerms.description.getURI());
+ multiPart.field("ol", description);
+ }
+
+ return multiPart;
+ }
+
+ static String detectContentType(Path file) throws IOException
+ {
+ String detected = Files.probeContentType(file);
+ return detected != null ? detected : "application/octet-stream";
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddGenericService.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddGenericService.java
new file mode 100644
index 0000000000..cb737c570a
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddGenericService.java
@@ -0,0 +1,112 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.A;
+import com.atomgraph.linkeddatahub.cli.vocab.SD;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Appends a generic SPARQL service description to a document. Mirrors bin/add-generic-service.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-generic-service", description = "Appends a generic SPARQL service to a document.")
+public class AddGenericService extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the service")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the service (optional)")
+ private String description;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the service (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--endpoint", required = true, paramLabel = "ENDPOINT_URI", description = "URI of the SPARQL endpoint")
+ private URI endpoint;
+
+ @Option(names = "--graph-store", paramLabel = "GRAPH_STORE_URI", description = "URI of the Graph Store Protocol endpoint (optional)")
+ private URI graphStore;
+
+ @Option(names = "--auth-user", paramLabel = "AUTH_USER", description = "Username for HTTP Basic auth (optional)")
+ private String authUser;
+
+ @Option(names = "--auth-pwd", paramLabel = "AUTH_PASSWORD", description = "Password for HTTP Basic auth (optional)")
+ private String authPwd;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ post(getClient(), target, buildModel(target, uri, title, endpoint, graphStore, authUser, authPwd, description));
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Builds the service description.
+ *
+ * @param target target document URI
+ * @param uri service URI (optional)
+ * @param title service title
+ * @param endpoint SPARQL endpoint URI
+ * @param graphStore Graph Store Protocol endpoint URI (optional)
+ * @param authUser HTTP Basic auth username (optional)
+ * @param authPwd HTTP Basic auth password (optional)
+ * @param description service description (optional)
+ * @return service model
+ */
+ public static Model buildModel(URI target, String uri, String title, URI endpoint, URI graphStore, String authUser, String authPwd, String description)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource service = createSubject(model, target, uri).
+ addProperty(RDF.type, SD.Service).
+ addProperty(DCTerms.title, title).
+ addProperty(SD.endpoint, model.createResource(endpoint.toString())).
+ addProperty(SD.supportedLanguage, SD.SPARQL11Query).
+ addProperty(SD.supportedLanguage, SD.SPARQL11Update);
+ if (graphStore != null) service.addProperty(A.graphStore, model.createResource(graphStore.toString()));
+ if (authUser != null) service.addProperty(A.authUser, authUser);
+ if (authPwd != null) service.addProperty(A.authPwd, authPwd);
+ if (description != null) service.addProperty(DCTerms.description, description);
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddResultSetChart.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddResultSetChart.java
new file mode 100644
index 0000000000..9b15553827
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddResultSetChart.java
@@ -0,0 +1,110 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.LDH;
+import com.atomgraph.linkeddatahub.cli.vocab.SPIN;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Appends a chart of SPARQL SELECT results to a document. Mirrors bin/add-result-set-chart.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-result-set-chart", description = "Appends a result set chart to a document.")
+public class AddResultSetChart extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the chart")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the chart (optional)")
+ private String description;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the chart (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--query", required = true, paramLabel = "QUERY_URI", description = "URI of the SELECT query")
+ private URI query;
+
+ @Option(names = "--chart-type", required = true, paramLabel = "TYPE_URI", description = "URI of the chart type")
+ private URI chartType;
+
+ @Option(names = "--category-var-name", required = true, paramLabel = "VAR_NAME", description = "Name of the category variable")
+ private String categoryVarName;
+
+ @Option(names = "--series-var-name", required = true, paramLabel = "VAR_NAME", description = "Name of the series variable")
+ private String seriesVarName;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ post(getClient(), target, buildModel(target, uri, title, query, chartType, categoryVarName, seriesVarName, description));
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Builds the chart description.
+ *
+ * @param target target document URI
+ * @param uri chart URI (optional)
+ * @param title chart title
+ * @param query SELECT query URI
+ * @param chartType chart type URI
+ * @param categoryVarName category variable name
+ * @param seriesVarName series variable name
+ * @param description chart description (optional)
+ * @return chart model
+ */
+ public static Model buildModel(URI target, String uri, String title, URI query, URI chartType, String categoryVarName, String seriesVarName, String description)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource chart = createSubject(model, target, uri).
+ addProperty(RDF.type, LDH.ResultSetChart).
+ addProperty(DCTerms.title, title).
+ addProperty(SPIN.query, model.createResource(query.toString())).
+ addProperty(LDH.chartType, model.createResource(chartType.toString())).
+ addProperty(LDH.categoryVarName, categoryVarName).
+ addProperty(LDH.seriesVarName, seriesVarName);
+ if (description != null) chart.addProperty(DCTerms.description, description);
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddSelect.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddSelect.java
new file mode 100644
index 0000000000..63698b3420
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddSelect.java
@@ -0,0 +1,71 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.SP;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Adds a SPARQL SELECT query to a document. Mirrors bin/add-select.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-select", description = "Adds a SELECT query to a document.")
+public class AddSelect extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the query")
+ private String title;
+
+ @Option(names = "--query-file", required = true, paramLabel = "ABS_PATH", description = "Path to the file with the query string")
+ private Path queryFile;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the query (optional)")
+ private String description;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the query (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--service", paramLabel = "SERVICE_URI", description = "URI of the SPARQL service (optional)")
+ private URI service;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ post(getClient(), target, AddConstruct.buildModel(target, uri, SP.Select, title, Files.readString(queryFile), service, description));
+ print(target);
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddView.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddView.java
new file mode 100644
index 0000000000..345775f45f
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddView.java
@@ -0,0 +1,101 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.AC;
+import com.atomgraph.linkeddatahub.cli.vocab.LDH;
+import com.atomgraph.linkeddatahub.cli.vocab.SPIN;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Appends a view of a SPARQL SELECT query to a document. Mirrors bin/add-view.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-view", description = "Appends a view to a document.")
+public class AddView extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--query", required = true, paramLabel = "QUERY_URI", description = "URI of the SELECT query")
+ private URI query;
+
+ @Option(names = "--title", paramLabel = "TITLE", description = "Title of the view (optional)")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the view (optional)")
+ private String description;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the view (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--mode", paramLabel = "MODE_URI", description = "URI of the layout mode (optional)")
+ private URI mode;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ post(getClient(), target, buildModel(target, uri, query, title, description, mode));
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Builds the view description.
+ *
+ * @param target target document URI
+ * @param uri view URI (optional)
+ * @param query SELECT query URI
+ * @param title view title (optional)
+ * @param description view description (optional)
+ * @param mode layout mode URI (optional)
+ * @return view model
+ */
+ public static Model buildModel(URI target, String uri, URI query, String title, String description, URI mode)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource view = createSubject(model, target, uri).
+ addProperty(RDF.type, LDH.View).
+ addProperty(SPIN.query, model.createResource(query.toString()));
+ if (title != null) view.addProperty(DCTerms.title, title);
+ if (description != null) view.addProperty(DCTerms.description, description);
+ if (mode != null) view.addProperty(AC.mode, model.createResource(mode.toString()));
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateContainer.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateContainer.java
new file mode 100644
index 0000000000..5e3e327e1d
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateContainer.java
@@ -0,0 +1,114 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.Slugs;
+import com.atomgraph.linkeddatahub.cli.util.URIRewriter;
+import com.atomgraph.linkeddatahub.cli.vocab.AC;
+import com.atomgraph.linkeddatahub.cli.vocab.DH;
+import com.atomgraph.linkeddatahub.cli.vocab.LDH;
+import com.atomgraph.linkeddatahub.cli.vocab.SPIN;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+
+/**
+ * Creates a container document. Mirrors bin/create-container.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "create-container", description = "Creates a container document.")
+public class CreateContainer extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the container")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the container (optional)")
+ private String description;
+
+ @Option(names = "--slug", paramLabel = "STRING", description = "String that will be used as URI path segment (optional)")
+ private String slug;
+
+ @Option(names = "--parent", required = true, paramLabel = "PARENT_URI", description = "URI of the parent container")
+ private URI parent;
+
+ @Option(names = "--block", paramLabel = "BLOCK_URI", description = "URI of the content block (optional)")
+ private URI block;
+
+ @Option(names = "--mode", paramLabel = "MODE_URI", description = "URI of the layout mode of the children view (optional)")
+ private URI mode;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ URI doc = URIRewriter.childURI(parent, slug != null ? slug : Slugs.defaultSlug());
+ put(getClient(), doc, buildModel(doc, title, description, block, mode));
+ print(doc);
+
+ return 0;
+ }
+
+ /**
+ * Builds the container document model with its first content block: the given block URI,
+ * a children view with an explicit mode, or the default children view.
+ *
+ * @param doc document URI
+ * @param title document title
+ * @param description document description (optional)
+ * @param block content block URI (optional)
+ * @param mode children view mode URI (optional, ignored when block is given)
+ * @return document model
+ */
+ public static Model buildModel(URI doc, String title, String description, URI block, URI mode)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource container = model.createResource(doc.toString()).
+ addProperty(RDF.type, DH.Container).
+ addProperty(DCTerms.title, title);
+
+ if (block != null) container.addProperty(RDF.li(1), model.createResource(block.toString()));
+ else if (mode != null) container.addProperty(RDF.li(1), model.createResource().
+ addProperty(RDF.type, LDH.Object).
+ addProperty(RDF.value, model.createResource().
+ addProperty(RDF.type, LDH.View).
+ addProperty(SPIN.query, LDH.SelectChildren).
+ addProperty(AC.mode, model.createResource(mode.toString()))));
+ else container.addProperty(RDF.li(1), model.createResource().
+ addProperty(RDF.type, LDH.Object).
+ addProperty(RDF.value, LDH.ChildrenView));
+
+ if (description != null) container.addProperty(DCTerms.description, description);
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateItem.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateItem.java
new file mode 100644
index 0000000000..6f2e49b830
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateItem.java
@@ -0,0 +1,90 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.Slugs;
+import com.atomgraph.linkeddatahub.cli.util.URIRewriter;
+import com.atomgraph.linkeddatahub.cli.vocab.DH;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+
+/**
+ * Creates an item document. Mirrors bin/create-item.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "create-item", description = "Creates an item document.")
+public class CreateItem extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the item")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the item (optional)")
+ private String description;
+
+ @Option(names = "--slug", paramLabel = "STRING", description = "String that will be used as URI path segment (optional)")
+ private String slug;
+
+ @Option(names = "--container", required = true, paramLabel = "CONTAINER_URI", description = "URI of the parent container")
+ private URI container;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ URI doc = URIRewriter.childURI(container, slug != null ? slug : Slugs.defaultSlug());
+ put(getClient(), doc, buildModel(doc, title, description));
+ print(doc);
+
+ return 0;
+ }
+
+ /**
+ * Builds the item document model.
+ *
+ * @param doc document URI
+ * @param title document title
+ * @param description document description (optional)
+ * @return document model
+ */
+ public static Model buildModel(URI doc, String title, String description)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource item = model.createResource(doc.toString()).
+ addProperty(RDF.type, DH.Item).
+ addProperty(DCTerms.title, title);
+ if (description != null) item.addProperty(DCTerms.description, description);
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Delete.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Delete.java
new file mode 100644
index 0000000000..27988751c1
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Delete.java
@@ -0,0 +1,45 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import java.net.URI;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Deletes an RDF document. Mirrors bin/delete.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "delete", description = "Deletes an RDF document.")
+public class Delete extends BaseCommand
+{
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ HttpException.check(target, getClient().delete(target)).close();
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Get.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Get.java
new file mode 100644
index 0000000000..2244ad231e
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Get.java
@@ -0,0 +1,63 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.net.URI;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Retrieves an RDF description. Mirrors bin/get.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "get", description = "Retrieves RDF description.")
+public class Get extends BaseCommand
+{
+
+ @Option(names = "--accept", required = true, paramLabel = "MEDIA_TYPE", description = "Requested media type (e.g. text/turtle)")
+ private String accept;
+
+ @Option(names = "--head", description = "Requested headers only, no body (HEAD method)")
+ private boolean head;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ MediaType[] acceptedTypes = { MediaType.valueOf(accept) };
+
+ if (head)
+ try (Response response = HttpException.check(target, getClient().head(target, acceptedTypes)))
+ {
+ print("HTTP " + response.getStatus() + " " + response.getStatusInfo().getReasonPhrase());
+ response.getStringHeaders().forEach((name, values) -> values.forEach(value -> print(name + ": " + value)));
+ }
+ else
+ printBody(HttpException.check(target, getClient().get(target, acceptedTypes)));
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Patch.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Patch.java
new file mode 100644
index 0000000000..f41a9deed0
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Patch.java
@@ -0,0 +1,52 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import org.apache.jena.query.Syntax;
+import org.apache.jena.update.UpdateFactory;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Patches an RDF document with a SPARQL update from standard input. Mirrors bin/patch.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "patch", description = "Patches an RDF document using SPARQL update from stdin.")
+public class Patch extends BaseCommand
+{
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ String update = new String(System.in.readAllBytes(), StandardCharsets.UTF_8);
+ // validate as standard SPARQL 1.1 before sending; the original text is sent unmodified
+ UpdateFactory.create(update, target.toString(), Syntax.syntaxSPARQL_11);
+
+ HttpException.check(target, getClient().patch(target, update)).close();
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Post.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Post.java
new file mode 100644
index 0000000000..8cc1aa393d
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Post.java
@@ -0,0 +1,54 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.MediaType;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Creates an RDF document from standard input. Mirrors bin/post.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "post", description = "Creates an RDF document from stdin.")
+public class Post extends BaseCommand
+{
+
+ @Option(names = {"-t", "--content-type"}, required = true, paramLabel = "MEDIA_TYPE", description = "Media type of the RDF body (e.g. text/turtle)")
+ private String contentType;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ Model model = readModel(contentType, target, System.in);
+ HttpException.check(target, getClient().post(target, Entity.entity(model, MediaType.valueOf(contentType)), ACCEPT_TURTLE)).close();
+ print(target);
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Put.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Put.java
new file mode 100644
index 0000000000..d30706740e
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Put.java
@@ -0,0 +1,54 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.MediaType;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Creates or updates an RDF document from standard input. Mirrors bin/put.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "put", description = "Creates or updates an RDF document from stdin.")
+public class Put extends BaseCommand
+{
+
+ @Option(names = {"-t", "--content-type"}, required = true, paramLabel = "MEDIA_TYPE", description = "Media type of the RDF body (e.g. text/turtle)")
+ private String contentType;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ Model model = readModel(contentType, target, System.in);
+ HttpException.check(target, getClient().put(target, Entity.entity(model, MediaType.valueOf(contentType)), ACCEPT_TURTLE)).close();
+ print(target);
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/AddOntologyImport.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/AddOntologyImport.java
new file mode 100644
index 0000000000..5a3ffc1498
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/AddOntologyImport.java
@@ -0,0 +1,50 @@
+/*
+ * 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.cli.command.admin;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.sparql.Updates;
+import java.net.URI;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Adds an owl:imports statement to an ontology. Mirrors bin/admin/add-ontology-import.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-ontology-import", description = "Adds an owl:imports statement to an ontology.")
+public class AddOntologyImport extends BaseCommand
+{
+
+ @Option(names = "--import", required = true, paramLabel = "IMPORT_URI", description = "URI of the imported ontology")
+ private URI importURI;
+
+ @Parameters(paramLabel = "ONTOLOGY_DOC_URI", description = "URI of the ontology document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ HttpException.check(target, getClient().patch(target, Updates.insertOntologyImport(target, importURI))).close();
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/Admin.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/Admin.java
new file mode 100644
index 0000000000..f2be93bdf6
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/Admin.java
@@ -0,0 +1,48 @@
+/*
+ * 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.cli.command.admin;
+
+import com.atomgraph.linkeddatahub.cli.command.admin.acl.Acl;
+import com.atomgraph.linkeddatahub.cli.command.admin.ontologies.Ontologies;
+import com.atomgraph.linkeddatahub.cli.command.admin.packages.Packages;
+import java.util.concurrent.Callable;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Model.CommandSpec;
+import picocli.CommandLine.ParameterException;
+import picocli.CommandLine.Spec;
+
+/**
+ * Administrative command group.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "admin",
+ description = "Administrative commands.",
+ subcommands = { Ontologies.class, Acl.class, Packages.class, ClearOntology.class, AddOntologyImport.class })
+public class Admin implements Callable
+{
+
+ @Spec
+ private CommandSpec spec;
+
+ @Override
+ public Integer call()
+ {
+ throw new ParameterException(spec.commandLine(), "Missing required subcommand");
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ClearOntology.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ClearOntology.java
new file mode 100644
index 0000000000..642451bb3e
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ClearOntology.java
@@ -0,0 +1,55 @@
+/*
+ * 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.cli.command.admin;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import jakarta.ws.rs.core.Form;
+import java.net.URI;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+
+/**
+ * Clears an ontology from memory so it gets reloaded. Mirrors bin/admin/clear-ontology.sh.
+ * The base URI is the base of the admin application.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "clear-ontology", description = "Clears an ontology from memory and reloads it.")
+public class ClearOntology extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--ontology", required = true, paramLabel = "ONTOLOGY_URI", description = "URI of the ontology")
+ private URI ontology;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ URI base = baseMixin.require(getSpec());
+ URI target = URI.create(base + "clear");
+
+ printBody(HttpException.check(target, getClient().postForm(target, new Form("uri", ontology.toString()), ACCEPT_TURTLE)));
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/Acl.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/Acl.java
new file mode 100644
index 0000000000..c44ef168dd
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/Acl.java
@@ -0,0 +1,45 @@
+/*
+ * 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.cli.command.admin.acl;
+
+import java.util.concurrent.Callable;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Model.CommandSpec;
+import picocli.CommandLine.ParameterException;
+import picocli.CommandLine.Spec;
+
+/**
+ * Access control command group.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "acl",
+ description = "Access control commands.",
+ subcommands = { CreateGroup.class, CreateAuthorization.class, AddAgentToGroup.class, MakePublic.class })
+public class Acl implements Callable
+{
+
+ @Spec
+ private CommandSpec spec;
+
+ @Override
+ public Integer call()
+ {
+ throw new ParameterException(spec.commandLine(), "Missing required subcommand");
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/AddAgentToGroup.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/AddAgentToGroup.java
new file mode 100644
index 0000000000..73abb82fff
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/AddAgentToGroup.java
@@ -0,0 +1,50 @@
+/*
+ * 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.cli.command.admin.acl;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.sparql.Updates;
+import java.net.URI;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Adds an agent to a group. Mirrors bin/admin/acl/add-agent-to-group.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-agent-to-group", description = "Adds an agent to a group.")
+public class AddAgentToGroup extends BaseCommand
+{
+
+ @Option(names = "--agent", required = true, paramLabel = "AGENT_URI", description = "URI of the agent")
+ private URI agent;
+
+ @Parameters(paramLabel = "GROUP_DOC_URI", description = "URI of the group document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ HttpException.check(target, getClient().patch(target, Updates.insertGroupMember(target, agent))).close();
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateAuthorization.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateAuthorization.java
new file mode 100644
index 0000000000..2c43b8ba46
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateAuthorization.java
@@ -0,0 +1,163 @@
+/*
+ * 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.cli.command.admin.acl;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.Slugs;
+import com.atomgraph.linkeddatahub.cli.util.URIRewriter;
+import com.atomgraph.linkeddatahub.cli.vocab.ACL;
+import com.atomgraph.linkeddatahub.cli.vocab.DH;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.sparql.vocabulary.FOAF;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import org.apache.jena.vocabulary.RDFS;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.ParameterException;
+
+/**
+ * Creates an ACL authorization. Mirrors bin/admin/acl/create-authorization.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "create-authorization", description = "Creates an ACL authorization.")
+public class CreateAuthorization extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the authorization")
+ private String label;
+
+ @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the authorization (optional)")
+ private String comment;
+
+ @Option(names = "--slug", paramLabel = "STRING", description = "String that will be used as URI path segment (optional)")
+ private String slug;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the authorization (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--agent", paramLabel = "AGENT_URI", description = "URI of an authorized agent (repeatable)")
+ private List agents = new ArrayList<>();
+
+ @Option(names = "--agent-class", paramLabel = "AGENT_CLASS_URI", description = "URI of an authorized agent class (repeatable)")
+ private List agentClasses = new ArrayList<>();
+
+ @Option(names = "--agent-group", paramLabel = "AGENT_GROUP_URI", description = "URI of an authorized agent group (repeatable)")
+ private List agentGroups = new ArrayList<>();
+
+ @Option(names = "--to", paramLabel = "TO_URI", description = "URI of an accessed document (repeatable)")
+ private List to = new ArrayList<>();
+
+ @Option(names = "--to-all-in", paramLabel = "CLASS_URI", description = "URI of an accessed document class (repeatable)")
+ private List toAllIn = new ArrayList<>();
+
+ @Option(names = "--append", description = "Grant acl:Append mode")
+ private boolean append;
+
+ @Option(names = "--control", description = "Grant acl:Control mode")
+ private boolean control;
+
+ @Option(names = "--read", description = "Grant acl:Read mode")
+ private boolean read;
+
+ @Option(names = "--write", description = "Grant acl:Write mode")
+ private boolean write;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ URI base = baseMixin.require(getSpec());
+ if (agents.isEmpty() && agentClasses.isEmpty() && agentGroups.isEmpty())
+ throw new ParameterException(getSpec().commandLine(), "At least one of '--agent', '--agent-class', '--agent-group' is required");
+ if (to.isEmpty() && toAllIn.isEmpty())
+ throw new ParameterException(getSpec().commandLine(), "At least one of '--to', '--to-all-in' is required");
+ if (!append && !control && !read && !write)
+ throw new ParameterException(getSpec().commandLine(), "At least one of '--append', '--control', '--read', '--write' is required");
+
+ URI doc = URIRewriter.childURI(URI.create(base + "acl/authorizations/"), slug != null ? slug : Slugs.defaultSlug());
+
+ List modes = new ArrayList<>();
+ if (append) modes.add(ACL.Append);
+ if (control) modes.add(ACL.Control);
+ if (read) modes.add(ACL.Read);
+ if (write) modes.add(ACL.Write);
+
+ put(getClient(), doc, buildModel(doc, uri, label, comment, agents, agentClasses, agentGroups, to, toAllIn, modes));
+ print(doc);
+
+ return 0;
+ }
+
+ /**
+ * Builds the authorization document model.
+ *
+ * @param doc document URI
+ * @param uri authorization URI (optional, blank node if null)
+ * @param label authorization label
+ * @param comment authorization comment (optional)
+ * @param agents authorized agent URIs
+ * @param agentClasses authorized agent class URIs
+ * @param agentGroups authorized agent group URIs
+ * @param to accessed document URIs
+ * @param toAllIn accessed document class URIs
+ * @param modes granted access modes
+ * @return document model
+ */
+ public static Model buildModel(URI doc, String uri, String label, String comment,
+ List agents, List agentClasses, List agentGroups,
+ List to, List toAllIn, List modes)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource auth = createSubject(model, doc, uri).
+ addProperty(RDF.type, ACL.Authorization).
+ addProperty(RDFS.label, label);
+ if (comment != null) auth.addProperty(RDFS.comment, comment);
+
+ model.createResource(doc.toString()).
+ addProperty(RDF.type, DH.Item).
+ addProperty(FOAF.primaryTopic, auth).
+ addProperty(DCTerms.title, label);
+
+ addResourceValues(auth, ACL.agent, agents);
+ addResourceValues(auth, ACL.agentClass, agentClasses);
+ addResourceValues(auth, ACL.agentGroup, agentGroups);
+ addResourceValues(auth, ACL.accessTo, to);
+ addResourceValues(auth, ACL.accessToClass, toAllIn);
+ modes.forEach(mode -> auth.addProperty(ACL.mode, mode));
+
+ return model;
+ }
+
+ static void addResourceValues(Resource subject, Property property, List values)
+ {
+ values.forEach(value -> subject.addProperty(property, subject.getModel().createResource(value.toString())));
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateGroup.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateGroup.java
new file mode 100644
index 0000000000..05ec6847e7
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateGroup.java
@@ -0,0 +1,103 @@
+/*
+ * 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.cli.command.admin.acl;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.Slugs;
+import com.atomgraph.linkeddatahub.cli.util.URIRewriter;
+import com.atomgraph.linkeddatahub.cli.vocab.DH;
+import java.net.URI;
+import java.util.List;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.sparql.vocabulary.FOAF;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+
+/**
+ * Creates an agent group. Mirrors bin/admin/acl/create-group.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "create-group", description = "Creates an agent group.")
+public class CreateGroup extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--name", required = true, paramLabel = "NAME", description = "Name of the group")
+ private String name;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the group (optional)")
+ private String description;
+
+ @Option(names = "--slug", paramLabel = "STRING", description = "String that will be used as URI path segment (optional)")
+ private String slug;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the group (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--member", required = true, paramLabel = "MEMBER_URI", description = "URI of a group member (repeatable)")
+ private List members;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ URI base = baseMixin.require(getSpec());
+ URI doc = URIRewriter.childURI(URI.create(base + "acl/groups/"), slug != null ? slug : Slugs.defaultSlug());
+
+ put(getClient(), doc, buildModel(doc, uri, name, description, members));
+ print(doc);
+
+ return 0;
+ }
+
+ /**
+ * Builds the group document model.
+ *
+ * @param doc document URI
+ * @param uri group URI (optional, blank node if null)
+ * @param name group name
+ * @param description group description (optional)
+ * @param members member agent URIs
+ * @return document model
+ */
+ public static Model buildModel(URI doc, String uri, String name, String description, List members)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource group = createSubject(model, doc, uri).
+ addProperty(RDF.type, FOAF.Group).
+ addProperty(FOAF.name, name);
+ if (description != null) group.addProperty(DCTerms.description, description);
+ members.forEach(member -> group.addProperty(FOAF.member, model.createResource(member.toString())));
+
+ model.createResource(doc.toString()).
+ addProperty(RDF.type, DH.Item).
+ addProperty(FOAF.primaryTopic, group).
+ addProperty(DCTerms.title, name);
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/MakePublic.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/MakePublic.java
new file mode 100644
index 0000000000..a041e3521f
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/MakePublic.java
@@ -0,0 +1,59 @@
+/*
+ * 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.cli.command.admin.acl;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.sparql.Updates;
+import com.atomgraph.linkeddatahub.cli.util.URIRewriter;
+import java.net.URI;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+
+/**
+ * Makes all end-user application documents publicly readable. Mirrors bin/admin/acl/make-public.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "make-public", description = "Makes all end-user application documents publicly readable.")
+public class MakePublic extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ URI base = baseMixin.require(getSpec());
+ URI adminBase = URIRewriter.adminBase(base);
+ URI target = URI.create(adminBase + "acl/authorizations/public/");
+
+ HttpException.check(target, getClient().patch(target, Updates.makePublic(base, adminBase))).close();
+
+ return 0;
+ }
+
+ @Override
+ protected URI getEffectiveProxy()
+ {
+ // the request targets the admin app, so the proxy origin gets the admin subdomain too
+ return getProxyMixin().getProxy() != null ? URIRewriter.adminBase(getProxyMixin().getProxy()) : null;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddClass.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddClass.java
new file mode 100644
index 0000000000..64c097ce2a
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddClass.java
@@ -0,0 +1,107 @@
+/*
+ * 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.cli.command.admin.ontologies;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.SPIN;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.OWL;
+import org.apache.jena.vocabulary.RDF;
+import org.apache.jena.vocabulary.RDFS;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Adds a class to an ontology. Mirrors bin/admin/ontologies/add-class.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-class", description = "Adds a class to an ontology.")
+public class AddClass extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the class")
+ private String label;
+
+ @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the class (optional)")
+ private String comment;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the class (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--constructor", paramLabel = "CONSTRUCT_URI", description = "URI of the constructor query (optional)")
+ private URI constructor;
+
+ @Option(names = "--constraint", paramLabel = "CONSTRAINT_URI", description = "URI of the constraint (optional)")
+ private URI constraint;
+
+ @Option(names = "--sub-class-of", paramLabel = "SUPER_CLASS_URI", description = "URI of a superclass (optional, repeatable)")
+ private List superClasses = new ArrayList<>();
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the ontology document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ post(getClient(), target, buildModel(target, uri, label, comment, constructor, constraint, superClasses));
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Builds the class description.
+ *
+ * @param target target document URI
+ * @param uri class URI (optional)
+ * @param label class label
+ * @param comment class comment (optional)
+ * @param constructor constructor query URI (optional)
+ * @param constraint constraint URI (optional)
+ * @param superClasses superclass URIs
+ * @return class model
+ */
+ public static Model buildModel(URI target, String uri, String label, String comment, URI constructor, URI constraint, List superClasses)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource cls = createSubject(model, target, uri).
+ addProperty(RDF.type, OWL.Class).
+ addProperty(RDFS.label, label);
+ if (comment != null) cls.addProperty(RDFS.comment, comment);
+ if (constructor != null) cls.addProperty(SPIN.constructor, model.createResource(constructor.toString()));
+ if (constraint != null) cls.addProperty(SPIN.constraint, model.createResource(constraint.toString()));
+ superClasses.forEach(superClass -> cls.addProperty(RDFS.subClassOf, model.createResource(superClass.toString())));
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddConstructor.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddConstructor.java
new file mode 100644
index 0000000000..d5f222b642
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddConstructor.java
@@ -0,0 +1,101 @@
+/*
+ * 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.cli.command.admin.ontologies;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.LDH;
+import com.atomgraph.linkeddatahub.cli.vocab.SP;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.RDF;
+import org.apache.jena.vocabulary.RDFS;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Adds a SPARQL CONSTRUCT constructor query to an ontology. Mirrors bin/admin/ontologies/add-constructor.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-constructor", description = "Adds a CONSTRUCT query to an ontology.")
+public class AddConstructor extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the query")
+ private String label;
+
+ @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the query (optional)")
+ private String comment;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the query (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--query-file", required = true, paramLabel = "ABS_PATH", description = "Path to the file with the query string")
+ private Path queryFile;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the ontology document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ post(getClient(), target, buildModel(target, uri, SP.Construct, label, Files.readString(queryFile), null, comment));
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Builds an ontology SPIN query description (labeled with rdfs:label,
+ * unlike the dct:title-based document queries).
+ *
+ * @param target target document URI
+ * @param uri query URI (optional)
+ * @param queryType SPIN query class (sp:Construct or sp:Select)
+ * @param label query label
+ * @param queryText query string
+ * @param service SPARQL service URI (optional)
+ * @param comment query comment (optional)
+ * @return query model
+ */
+ public static Model buildModel(URI target, String uri, Resource queryType, String label, String queryText, URI service, String comment)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource query = createSubject(model, target, uri).
+ addProperty(RDF.type, queryType).
+ addProperty(RDFS.label, label).
+ addProperty(SP.text, queryText);
+ if (comment != null) query.addProperty(RDFS.comment, comment);
+ if (service != null) query.addProperty(LDH.service, model.createResource(service.toString()));
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddPropertyConstraint.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddPropertyConstraint.java
new file mode 100644
index 0000000000..74ff2db865
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddPropertyConstraint.java
@@ -0,0 +1,95 @@
+/*
+ * 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.cli.command.admin.ontologies;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.LDH;
+import com.atomgraph.linkeddatahub.cli.vocab.SP;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.RDF;
+import org.apache.jena.vocabulary.RDFS;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Adds a constraint that makes a property required. Mirrors bin/admin/ontologies/add-property-constraint.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-property-constraint", description = "Adds a constraint that makes a property required.")
+public class AddPropertyConstraint extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the constraint")
+ private String label;
+
+ @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the constraint (optional)")
+ private String comment;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the constraint (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--property", required = true, paramLabel = "PROPERTY_URI", description = "URI of the required property")
+ private URI property;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the ontology document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ post(getClient(), target, buildModel(target, uri, label, property, comment));
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Builds the constraint description.
+ *
+ * @param target target document URI
+ * @param uri constraint URI (optional)
+ * @param label constraint label
+ * @param property required property URI
+ * @param comment constraint comment (optional)
+ * @return constraint model
+ */
+ public static Model buildModel(URI target, String uri, String label, URI property, String comment)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource constraint = createSubject(model, target, uri).
+ addProperty(RDF.type, LDH.MissingPropertyValue).
+ addProperty(RDFS.label, label).
+ addProperty(SP.arg1, model.createResource(property.toString()));
+ if (comment != null) constraint.addProperty(RDFS.comment, comment);
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddRestriction.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddRestriction.java
new file mode 100644
index 0000000000..c2b6ee6cea
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddRestriction.java
@@ -0,0 +1,104 @@
+/*
+ * 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.cli.command.admin.ontologies;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.OWL;
+import org.apache.jena.vocabulary.RDF;
+import org.apache.jena.vocabulary.RDFS;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Adds an OWL restriction to an ontology. Mirrors bin/admin/ontologies/add-restriction.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-restriction", description = "Adds an OWL restriction to an ontology.")
+public class AddRestriction extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the restriction")
+ private String label;
+
+ @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the restriction (optional)")
+ private String comment;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the restriction (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--on-property", paramLabel = "PROPERTY_URI", description = "URI of the restricted property (optional)")
+ private URI onProperty;
+
+ @Option(names = "--all-values-from", paramLabel = "URI", description = "URI of the value class (optional)")
+ private URI allValuesFrom;
+
+ @Option(names = "--has-value", paramLabel = "URI", description = "URI of the value resource (optional)")
+ private URI hasValue;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the ontology document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ post(getClient(), target, buildModel(target, uri, label, comment, onProperty, allValuesFrom, hasValue));
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Builds the restriction description.
+ *
+ * @param target target document URI
+ * @param uri restriction URI (optional)
+ * @param label restriction label
+ * @param comment restriction comment (optional)
+ * @param onProperty restricted property URI (optional)
+ * @param allValuesFrom value class URI (optional)
+ * @param hasValue value resource URI (optional)
+ * @return restriction model
+ */
+ public static Model buildModel(URI target, String uri, String label, String comment, URI onProperty, URI allValuesFrom, URI hasValue)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource restriction = createSubject(model, target, uri).
+ addProperty(RDF.type, OWL.Restriction).
+ addProperty(RDFS.label, label);
+ if (comment != null) restriction.addProperty(RDFS.comment, comment);
+ if (onProperty != null) restriction.addProperty(OWL.onProperty, model.createResource(onProperty.toString()));
+ if (allValuesFrom != null) restriction.addProperty(OWL.allValuesFrom, model.createResource(allValuesFrom.toString()));
+ if (hasValue != null) restriction.addProperty(OWL.hasValue, model.createResource(hasValue.toString()));
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddSelect.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddSelect.java
new file mode 100644
index 0000000000..0938ffc9e4
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddSelect.java
@@ -0,0 +1,71 @@
+/*
+ * 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.cli.command.admin.ontologies;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.SP;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Adds a SPARQL SELECT query to an ontology. Mirrors bin/admin/ontologies/add-select.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-select", description = "Adds a SELECT query to an ontology.")
+public class AddSelect extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the query")
+ private String label;
+
+ @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the query (optional)")
+ private String comment;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the query (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--query-file", required = true, paramLabel = "ABS_PATH", description = "Path to the file with the query string")
+ private Path queryFile;
+
+ @Option(names = "--service", paramLabel = "SERVICE_URI", description = "URI of the SPARQL service (optional)")
+ private URI service;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the ontology document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ post(getClient(), target, AddConstructor.buildModel(target, uri, SP.Select, label, Files.readString(queryFile), service, comment));
+ print(target);
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/CreateOntology.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/CreateOntology.java
new file mode 100644
index 0000000000..96cface9ff
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/CreateOntology.java
@@ -0,0 +1,99 @@
+/*
+ * 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.cli.command.admin.ontologies;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.Slugs;
+import com.atomgraph.linkeddatahub.cli.util.URIRewriter;
+import com.atomgraph.linkeddatahub.cli.vocab.DH;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.sparql.vocabulary.FOAF;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.OWL;
+import org.apache.jena.vocabulary.RDF;
+import org.apache.jena.vocabulary.RDFS;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+
+/**
+ * Creates a new ontology document. Mirrors bin/admin/ontologies/create-ontology.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "create-ontology", description = "Creates a new ontology.")
+public class CreateOntology extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the ontology")
+ private String label;
+
+ @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the ontology (optional)")
+ private String comment;
+
+ @Option(names = "--slug", paramLabel = "STRING", description = "String that will be used as URI path segment (optional)")
+ private String slug;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the ontology (optional, blank node if not set)")
+ private String uri;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ URI base = baseMixin.require(getSpec());
+ URI doc = URIRewriter.childURI(URI.create(base + "ontologies/"), slug != null ? slug : Slugs.defaultSlug());
+
+ put(getClient(), doc, buildModel(doc, uri, label, comment));
+ print(doc);
+
+ return 0;
+ }
+
+ /**
+ * Builds the ontology document model.
+ *
+ * @param doc document URI
+ * @param uri ontology URI (optional, blank node if null)
+ * @param label ontology label
+ * @param comment ontology comment (optional)
+ * @return document model
+ */
+ public static Model buildModel(URI doc, String uri, String label, String comment)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource ontology = createSubject(model, doc, uri).
+ addProperty(RDF.type, OWL.Ontology).
+ addProperty(RDFS.label, label);
+ if (comment != null) ontology.addProperty(RDFS.comment, comment);
+
+ model.createResource(doc.toString()).
+ addProperty(RDF.type, DH.Item).
+ addProperty(FOAF.primaryTopic, ontology).
+ addProperty(DCTerms.title, label);
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/ImportOntology.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/ImportOntology.java
new file mode 100644
index 0000000000..a91607456b
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/ImportOntology.java
@@ -0,0 +1,83 @@
+/*
+ * 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.cli.command.admin.ontologies;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.SD;
+import com.atomgraph.linkeddatahub.cli.vocab.SPIN;
+import jakarta.ws.rs.client.Entity;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.vocabulary.DCTerms;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+
+/**
+ * Imports an external ontology into a named graph via the transform endpoint.
+ * Mirrors bin/admin/ontologies/import-ontology.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "import-ontology", description = "Imports an external ontology into a named graph.")
+public class ImportOntology extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--source", required = true, paramLabel = "SOURCE_URI", description = "URI of the imported ontology")
+ private URI source;
+
+ @Option(names = "--graph", required = true, paramLabel = "GRAPH_URI", description = "URI of the named graph the ontology is imported into")
+ private URI graph;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ URI base = baseMixin.require(getSpec());
+ URI target = URI.create(base + "transform");
+
+ printBody(HttpException.check(target, getClient().post(target, Entity.entity(buildModel(base, source, graph), TEXT_TURTLE_TYPE), ACCEPT_TURTLE)));
+
+ return 0;
+ }
+
+ /**
+ * Builds the transform argument description.
+ *
+ * @param base admin application base URI
+ * @param source imported ontology URI
+ * @param graph target named graph URI
+ * @return argument model
+ */
+ public static Model buildModel(URI base, URI source, URI graph)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ model.createResource().
+ addProperty(DCTerms.source, model.createResource(source.toString())).
+ addProperty(SD.name, model.createResource(graph.toString())).
+ addProperty(SPIN.query, model.createResource(base + "queries/construct-constructors/#this"));
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/Ontologies.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/Ontologies.java
new file mode 100644
index 0000000000..e7fdbdec41
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/Ontologies.java
@@ -0,0 +1,46 @@
+/*
+ * 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.cli.command.admin.ontologies;
+
+import java.util.concurrent.Callable;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Model.CommandSpec;
+import picocli.CommandLine.ParameterException;
+import picocli.CommandLine.Spec;
+
+/**
+ * Ontology management command group.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "ontologies",
+ description = "Ontology management commands.",
+ subcommands = { CreateOntology.class, ImportOntology.class, AddClass.class, AddConstructor.class,
+ AddSelect.class, AddPropertyConstraint.class, AddRestriction.class })
+public class Ontologies implements Callable
+{
+
+ @Spec
+ private CommandSpec spec;
+
+ @Override
+ public Integer call()
+ {
+ throw new ParameterException(spec.commandLine(), "Missing required subcommand");
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/packages/InstallPackage.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/packages/InstallPackage.java
new file mode 100644
index 0000000000..f2167060d9
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/packages/InstallPackage.java
@@ -0,0 +1,60 @@
+/*
+ * 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.cli.command.admin.packages;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.URIRewriter;
+import jakarta.ws.rs.core.Form;
+import java.net.URI;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+
+/**
+ * Installs a LinkedDataHub package. Mirrors bin/admin/packages/install-package.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "install-package", description = "Installs a package.")
+public class InstallPackage extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--package", required = true, paramLabel = "PACKAGE_URI", description = "URI of the package")
+ private URI packageURI;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ URI target = URI.create(URIRewriter.adminBase(baseMixin.require(getSpec())) + "packages/install");
+ HttpException.check(target, getClient().postForm(target, new Form("package-uri", packageURI.toString()), ACCEPT_TURTLE)).close();
+
+ return 0;
+ }
+
+ @Override
+ protected URI getEffectiveProxy()
+ {
+ // the request targets the admin app, so the proxy origin gets the admin subdomain too
+ return getProxyMixin().getProxy() != null ? URIRewriter.adminBase(getProxyMixin().getProxy()) : null;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/packages/Packages.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/packages/Packages.java
new file mode 100644
index 0000000000..79191eb131
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/packages/Packages.java
@@ -0,0 +1,45 @@
+/*
+ * 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.cli.command.admin.packages;
+
+import java.util.concurrent.Callable;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Model.CommandSpec;
+import picocli.CommandLine.ParameterException;
+import picocli.CommandLine.Spec;
+
+/**
+ * Package management command group.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "packages",
+ description = "Package management commands.",
+ subcommands = { InstallPackage.class, UninstallPackage.class })
+public class Packages implements Callable
+{
+
+ @Spec
+ private CommandSpec spec;
+
+ @Override
+ public Integer call()
+ {
+ throw new ParameterException(spec.commandLine(), "Missing required subcommand");
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/packages/UninstallPackage.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/packages/UninstallPackage.java
new file mode 100644
index 0000000000..78f463673d
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/packages/UninstallPackage.java
@@ -0,0 +1,60 @@
+/*
+ * 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.cli.command.admin.packages;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.URIRewriter;
+import jakarta.ws.rs.core.Form;
+import java.net.URI;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+
+/**
+ * Uninstalls a LinkedDataHub package. Mirrors bin/admin/packages/uninstall-package.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "uninstall-package", description = "Uninstalls a package.")
+public class UninstallPackage extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--package", required = true, paramLabel = "PACKAGE_URI", description = "URI of the package")
+ private URI packageURI;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ URI target = URI.create(URIRewriter.adminBase(baseMixin.require(getSpec())) + "packages/uninstall");
+ HttpException.check(target, getClient().postForm(target, new Form("package-uri", packageURI.toString()), ACCEPT_TURTLE)).close();
+
+ return 0;
+ }
+
+ @Override
+ protected URI getEffectiveProxy()
+ {
+ // the request targets the admin app, so the proxy origin gets the admin subdomain too
+ return getProxyMixin().getProxy() != null ? URIRewriter.adminBase(getProxyMixin().getProxy()) : null;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddObjectBlock.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddObjectBlock.java
new file mode 100644
index 0000000000..b9860d23dc
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddObjectBlock.java
@@ -0,0 +1,121 @@
+/*
+ * 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.cli.command.content;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.SequenceNumbers;
+import com.atomgraph.linkeddatahub.cli.vocab.AC;
+import com.atomgraph.linkeddatahub.cli.vocab.LDH;
+import jakarta.ws.rs.core.Response;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.rdf.model.ResourceFactory;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Appends an object content block to a document. Mirrors bin/content/add-object-block.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-object-block", description = "Appends an object content block to a document.")
+public class AddObjectBlock extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin; // accepted for script interface parity, unused
+
+ @Option(names = "--value", required = true, paramLabel = "RESOURCE_URI", description = "URI of the object resource")
+ private URI value;
+
+ @Option(names = "--title", paramLabel = "TITLE", description = "Title of the block (optional)")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the block (optional)")
+ private String description;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the block (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--mode", paramLabel = "MODE_URI", description = "URI of the layout mode (optional)")
+ private URI mode;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ post(getClient(), target, buildModel(target, nextSequenceProperty(), uri, value, title, description, mode));
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Fetches the target document and returns the next free rdf:_N membership property.
+ *
+ * @return membership property
+ */
+ protected Property nextSequenceProperty()
+ {
+ Model current;
+ try (Response response = HttpException.check(target, getClient().get(target, ACCEPT_NTRIPLES)))
+ {
+ current = response.readEntity(Model.class);
+ }
+
+ return SequenceNumbers.nextSequenceProperty(current, ResourceFactory.createResource(target.toString()));
+ }
+
+ /**
+ * Builds the object block description.
+ *
+ * @param target target document URI
+ * @param seq membership property (rdf:_N)
+ * @param uri block URI (optional)
+ * @param value object resource URI
+ * @param title block title (optional)
+ * @param description block description (optional)
+ * @param mode layout mode URI (optional)
+ * @return block model
+ */
+ public static Model buildModel(URI target, Property seq, String uri, URI value, String title, String description, URI mode)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource block = createSubject(model, target, uri).
+ addProperty(RDF.type, LDH.Object).
+ addProperty(RDF.value, model.createResource(value.toString()));
+ model.createResource(target.toString()).addProperty(seq, block);
+ if (title != null) block.addProperty(DCTerms.title, title);
+ if (description != null) block.addProperty(DCTerms.description, description);
+ if (mode != null) block.addProperty(AC.mode, model.createResource(mode.toString()));
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddXHTMLBlock.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddXHTMLBlock.java
new file mode 100644
index 0000000000..7995fd0c2b
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddXHTMLBlock.java
@@ -0,0 +1,106 @@
+/*
+ * 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.cli.command.content;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.SequenceNumbers;
+import com.atomgraph.linkeddatahub.cli.vocab.LDH;
+import jakarta.ws.rs.core.Response;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.rdf.model.ResourceFactory;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Appends an XHTML content block to a document. Mirrors bin/content/add-xhtml-block.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-xhtml-block", description = "Appends an XHTML content block to a document.")
+public class AddXHTMLBlock extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin; // accepted for script interface parity, unused
+
+ @Option(names = "--value", required = true, paramLabel = "XHTML", description = "XHTML content (must be well-formed XML)")
+ private String value;
+
+ @Option(names = "--title", paramLabel = "TITLE", description = "Title of the block (optional)")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the block (optional)")
+ private String description;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the block (optional, blank node if not set)")
+ private String uri;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ Model current;
+ try (Response response = HttpException.check(target, getClient().get(target, ACCEPT_NTRIPLES)))
+ {
+ current = response.readEntity(Model.class);
+ }
+ Property seq = SequenceNumbers.nextSequenceProperty(current, ResourceFactory.createResource(target.toString()));
+
+ post(getClient(), target, buildModel(target, seq, uri, value, title, description));
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Builds the XHTML block description.
+ *
+ * @param target target document URI
+ * @param seq membership property (rdf:_N)
+ * @param uri block URI (optional)
+ * @param value XHTML content
+ * @param title block title (optional)
+ * @param description block description (optional)
+ * @return block model
+ */
+ public static Model buildModel(URI target, Property seq, String uri, String value, String title, String description)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource block = createSubject(model, target, uri).
+ addProperty(RDF.type, LDH.XHTML).
+ addProperty(RDF.value, model.createTypedLiteral(value, RDF.dtXMLLiteral));
+ model.createResource(target.toString()).addProperty(seq, block);
+ if (title != null) block.addProperty(DCTerms.title, title);
+ if (description != null) block.addProperty(DCTerms.description, description);
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/Content.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/Content.java
new file mode 100644
index 0000000000..900db7912c
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/Content.java
@@ -0,0 +1,45 @@
+/*
+ * 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.cli.command.content;
+
+import java.util.concurrent.Callable;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Model.CommandSpec;
+import picocli.CommandLine.ParameterException;
+import picocli.CommandLine.Spec;
+
+/**
+ * Content block command group.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "content",
+ description = "Content block commands.",
+ subcommands = { AddObjectBlock.class, AddXHTMLBlock.class, RemoveBlock.class })
+public class Content implements Callable
+{
+
+ @Spec
+ private CommandSpec spec;
+
+ @Override
+ public Integer call()
+ {
+ throw new ParameterException(spec.commandLine(), "Missing required subcommand");
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/RemoveBlock.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/RemoveBlock.java
new file mode 100644
index 0000000000..dd6bfee7cd
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/RemoveBlock.java
@@ -0,0 +1,50 @@
+/*
+ * 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.cli.command.content;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.HttpException;
+import com.atomgraph.linkeddatahub.cli.sparql.Updates;
+import java.net.URI;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Removes a content block (or all content blocks) from a document. Mirrors bin/content/remove-block.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "remove-block", description = "Removes a content block from a document.")
+public class RemoveBlock extends BaseCommand
+{
+
+ @Option(names = "--block", paramLabel = "BLOCK_URI", description = "URI of the content block (optional, all blocks if not set)")
+ private URI block;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ HttpException.check(target, getClient().patch(target, Updates.removeBlock(target, block))).close();
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddCSVImport.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddCSVImport.java
new file mode 100644
index 0000000000..0decc0d175
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddCSVImport.java
@@ -0,0 +1,123 @@
+/*
+ * 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.cli.command.imports;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.LDHClient;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.LDH;
+import com.atomgraph.linkeddatahub.cli.vocab.SPIN;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Adds CSV import metadata to a document. Mirrors bin/imports/add-csv-import.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-csv-import", description = "Adds CSV import metadata to a document.")
+public class AddCSVImport extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the import")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the import (optional)")
+ private String description;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the import (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--query", required = true, paramLabel = "QUERY_URI", description = "URI of the transformation CONSTRUCT query")
+ private URI query;
+
+ @Option(names = "--file", required = true, paramLabel = "FILE_URI", description = "URI of the uploaded CSV file")
+ private URI file;
+
+ @Option(names = "--delimiter", defaultValue = ",", paramLabel = "CHAR", description = "CSV delimiter character (default: ${DEFAULT-VALUE})")
+ private String delimiter;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ core(getClient(), target, uri, title, query, file, delimiter, description);
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Appends the CSV import metadata to the target document.
+ *
+ * @param client client instance
+ * @param target target document URI
+ * @param uri import URI (optional)
+ * @param title import title
+ * @param query transformation query URI
+ * @param file uploaded file URI
+ * @param delimiter CSV delimiter
+ * @param description import description (optional)
+ */
+ public static void core(LDHClient client, URI target, String uri, String title, URI query, URI file, String delimiter, String description)
+ {
+ post(client, target, buildModel(target, uri, title, query, file, delimiter, description));
+ }
+
+ /**
+ * Builds the CSV import description.
+ *
+ * @param target target document URI
+ * @param uri import URI (optional)
+ * @param title import title
+ * @param query transformation query URI
+ * @param file uploaded file URI
+ * @param delimiter CSV delimiter
+ * @param description import description (optional)
+ * @return import model
+ */
+ public static Model buildModel(URI target, String uri, String title, URI query, URI file, String delimiter, String description)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource csvImport = createSubject(model, target, uri).
+ addProperty(RDF.type, LDH.CSVImport).
+ addProperty(DCTerms.title, title).
+ addProperty(SPIN.query, model.createResource(query.toString())).
+ addProperty(LDH.file, model.createResource(file.toString())).
+ addProperty(LDH.delimiter, delimiter);
+ if (description != null) csvImport.addProperty(DCTerms.description, description);
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddRDFImport.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddRDFImport.java
new file mode 100644
index 0000000000..4b91effcf6
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddRDFImport.java
@@ -0,0 +1,124 @@
+/*
+ * 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.cli.command.imports;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.http.LDHClient;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.vocab.LDH;
+import com.atomgraph.linkeddatahub.cli.vocab.SD;
+import com.atomgraph.linkeddatahub.cli.vocab.SPIN;
+import java.net.URI;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.DCTerms;
+import org.apache.jena.vocabulary.RDF;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Adds RDF import metadata to a document. Mirrors bin/imports/add-rdf-import.sh.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "add-rdf-import", description = "Adds RDF import metadata to a document.")
+public class AddRDFImport extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the import")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the import (optional)")
+ private String description;
+
+ @Option(names = "--uri", paramLabel = "URI", description = "URI of the import (optional, blank node if not set)")
+ private String uri;
+
+ @Option(names = "--query", paramLabel = "QUERY_URI", description = "URI of the transformation CONSTRUCT query (optional)")
+ private URI query;
+
+ @Option(names = "--graph", paramLabel = "GRAPH_URI", description = "URI of the target named graph (optional)")
+ private URI graph;
+
+ @Option(names = "--file", required = true, paramLabel = "FILE_URI", description = "URI of the uploaded RDF file")
+ private URI file;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ baseMixin.require(getSpec()); // required by the script interface
+
+ core(getClient(), target, uri, title, file, query, graph, description);
+ print(target);
+
+ return 0;
+ }
+
+ /**
+ * Appends the RDF import metadata to the target document.
+ *
+ * @param client client instance
+ * @param target target document URI
+ * @param uri import URI (optional)
+ * @param title import title
+ * @param file uploaded file URI
+ * @param query transformation query URI (optional)
+ * @param graph target named graph URI (optional)
+ * @param description import description (optional)
+ */
+ public static void core(LDHClient client, URI target, String uri, String title, URI file, URI query, URI graph, String description)
+ {
+ post(client, target, buildModel(target, uri, title, file, query, graph, description));
+ }
+
+ /**
+ * Builds the RDF import description.
+ *
+ * @param target target document URI
+ * @param uri import URI (optional)
+ * @param title import title
+ * @param file uploaded file URI
+ * @param query transformation query URI (optional)
+ * @param graph target named graph URI (optional)
+ * @param description import description (optional)
+ * @return import model
+ */
+ public static Model buildModel(URI target, String uri, String title, URI file, URI query, URI graph, String description)
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ Resource rdfImport = createSubject(model, target, uri).
+ addProperty(RDF.type, LDH.RDFImport).
+ addProperty(DCTerms.title, title).
+ addProperty(LDH.file, model.createResource(file.toString()));
+ if (graph != null) rdfImport.addProperty(SD.name, model.createResource(graph.toString()));
+ if (query != null) rdfImport.addProperty(SPIN.query, model.createResource(query.toString()));
+ if (description != null) rdfImport.addProperty(DCTerms.description, description);
+
+ return model;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportCSV.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportCSV.java
new file mode 100644
index 0000000000..ea45095184
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportCSV.java
@@ -0,0 +1,81 @@
+/*
+ * 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.cli.command.imports;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.command.AddConstruct;
+import com.atomgraph.linkeddatahub.cli.command.AddFile;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.Slugs;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Imports CSV data: adds the transformation query, uploads the CSV file and creates the
+ * import metadata on the target document. Mirrors bin/imports/import-csv.sh,
+ * calling the same steps in-process instead of via subscripts.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "import-csv", description = "Imports CSV data using a transformation query.")
+public class ImportCSV extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the import")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the import (optional)")
+ private String description;
+
+ @Option(names = "--query-file", required = true, paramLabel = "ABS_PATH", description = "Path to the file with the transformation CONSTRUCT query")
+ private Path queryFile;
+
+ @Option(names = "--csv-file", required = true, paramLabel = "ABS_PATH", description = "Path to the CSV file")
+ private Path csvFile;
+
+ @Option(names = "--delimiter", defaultValue = ",", paramLabel = "CHAR", description = "CSV delimiter character (default: ${DEFAULT-VALUE})")
+ private String delimiter;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the import document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ URI base = baseMixin.require(getSpec());
+
+ String queryId = Slugs.defaultSlug();
+ AddConstruct.core(getClient(), target, "#" + queryId, title, Files.readString(queryFile), null, null);
+ URI query = target.resolve("#" + queryId);
+
+ URI fileURI = AddFile.core(getClient(), base, target, csvFile, "text/csv", title, null);
+
+ AddCSVImport.core(getClient(), target, null, title, query, fileURI, delimiter, description);
+ print(target);
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportRDF.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportRDF.java
new file mode 100644
index 0000000000..db3cbed84e
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportRDF.java
@@ -0,0 +1,89 @@
+/*
+ * 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.cli.command.imports;
+
+import com.atomgraph.linkeddatahub.cli.BaseCommand;
+import com.atomgraph.linkeddatahub.cli.command.AddConstruct;
+import com.atomgraph.linkeddatahub.cli.command.AddFile;
+import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin;
+import com.atomgraph.linkeddatahub.cli.util.Slugs;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Imports RDF data: optionally adds a transformation query, uploads the RDF file and creates
+ * the import metadata on the target document. Mirrors bin/imports/import-rdf.sh,
+ * calling the same steps in-process instead of via subscripts.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "import-rdf", description = "Imports RDF data, optionally using a transformation query.")
+public class ImportRDF extends BaseCommand
+{
+
+ @Mixin
+ private BaseMixin baseMixin;
+
+ @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the import")
+ private String title;
+
+ @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the import (optional)")
+ private String description;
+
+ @Option(names = "--query-file", paramLabel = "ABS_PATH", description = "Path to the file with the transformation CONSTRUCT query (optional)")
+ private Path queryFile;
+
+ @Option(names = "--rdf-file", required = true, paramLabel = "ABS_PATH", description = "Path to the RDF file")
+ private Path rdfFile;
+
+ @Option(names = "--content-type", required = true, paramLabel = "MEDIA_TYPE", description = "Media type of the RDF file (e.g. text/turtle)")
+ private String contentType;
+
+ @Option(names = "--graph", paramLabel = "GRAPH_URI", description = "URI of the target named graph (optional)")
+ private URI graph;
+
+ @Parameters(paramLabel = "TARGET_URI", description = "URI of the import document")
+ private URI target;
+
+ @Override
+ public Integer call() throws Exception
+ {
+ URI base = baseMixin.require(getSpec());
+
+ URI query = null;
+ if (queryFile != null)
+ {
+ String queryId = Slugs.defaultSlug();
+ AddConstruct.core(getClient(), target, "#" + queryId, title, Files.readString(queryFile), null, null);
+ query = target.resolve("#" + queryId);
+ }
+
+ URI fileURI = AddFile.core(getClient(), base, target, rdfFile, contentType, title, null);
+
+ String importId = Slugs.defaultSlug();
+ AddRDFImport.core(getClient(), target, "#" + importId, title, fileURI, query, query == null ? graph : null, description);
+ print(target);
+
+ return 0;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/Imports.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/Imports.java
new file mode 100644
index 0000000000..65b084d19c
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/Imports.java
@@ -0,0 +1,45 @@
+/*
+ * 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.cli.command.imports;
+
+import java.util.concurrent.Callable;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Model.CommandSpec;
+import picocli.CommandLine.ParameterException;
+import picocli.CommandLine.Spec;
+
+/**
+ * Data import command group.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Command(name = "imports",
+ description = "Data import commands.",
+ subcommands = { AddCSVImport.class, AddRDFImport.class, ImportCSV.class, ImportRDF.class })
+public class Imports implements Callable
+{
+
+ @Spec
+ private CommandSpec spec;
+
+ @Override
+ public Integer call()
+ {
+ throw new ParameterException(spec.commandLine(), "Missing required subcommand");
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/ClientFactory.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/ClientFactory.java
new file mode 100644
index 0000000000..2a7cf1f24c
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/ClientFactory.java
@@ -0,0 +1,127 @@
+/*
+ * 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.cli.http;
+
+import com.atomgraph.core.io.ModelProvider;
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.client.ClientBuilder;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+import org.apache.http.config.Registry;
+import org.apache.http.config.RegistryBuilder;
+import org.apache.http.conn.socket.ConnectionSocketFactory;
+import org.apache.http.conn.socket.PlainConnectionSocketFactory;
+import org.apache.http.conn.ssl.NoopHostnameVerifier;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
+import org.glassfish.jersey.apache.connector.ApacheClientProperties;
+import org.glassfish.jersey.apache.connector.ApacheConnectorProvider;
+import org.glassfish.jersey.client.ClientConfig;
+import org.glassfish.jersey.client.ClientProperties;
+import org.glassfish.jersey.client.RequestEntityProcessing;
+import org.glassfish.jersey.media.multipart.MultiPartFeature;
+
+/**
+ * Builds a Jersey HTTP client authenticated with a WebID client certificate from a PKCS12 keystore.
+ * Mirrors Application.getClient() in LinkedDataHub, with server certificate checks
+ * disabled (equivalent of curl -k against self-signed dev instances).
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class ClientFactory
+{
+
+ private ClientFactory() { }
+
+ /**
+ * Builds the client instance.
+ *
+ * @param keyStoreFile PKCS12 (.p12) keystore file with the WebID certificate
+ * @param keyStorePassword keystore password
+ * @return client instance
+ */
+ public static Client createClient(Path keyStoreFile, String keyStorePassword)
+ {
+ SSLContext ctx;
+ try
+ {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ try (InputStream is = Files.newInputStream(keyStoreFile))
+ {
+ keyStore.load(is, keyStorePassword.toCharArray());
+ }
+
+ // for client authentication
+ KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ kmf.init(keyStore, keyStorePassword.toCharArray());
+
+ ctx = SSLContext.getInstance("TLS");
+ ctx.init(kmf.getKeyManagers(), new TrustManager[] { TRUST_ALL }, new SecureRandom());
+ }
+ catch (IOException | GeneralSecurityException ex)
+ {
+ throw new IllegalArgumentException("Could not load PKCS12 keystore '" + keyStoreFile + "': " + ex.getMessage() + " (wrong password?)", ex);
+ }
+
+ Registry socketFactoryRegistry = RegistryBuilder.create().
+ register("https", new SSLConnectionSocketFactory(ctx, NoopHostnameVerifier.INSTANCE)).
+ register("http", new PlainConnectionSocketFactory()).
+ build();
+
+ ClientConfig config = new ClientConfig();
+ config.connectorProvider(new ApacheConnectorProvider());
+ config.register(MultiPartFeature.class);
+ config.register(new ModelProvider());
+ config.property(ClientProperties.FOLLOW_REDIRECTS, false); // scripts use curl without -L
+ config.property(ClientProperties.REQUEST_ENTITY_PROCESSING, RequestEntityProcessing.BUFFERED);
+ config.property(ApacheClientProperties.CONNECTION_MANAGER, new PoolingHttpClientConnectionManager(socketFactoryRegistry));
+
+ return ClientBuilder.newBuilder().
+ withConfig(config).
+ sslContext(ctx).
+ hostnameVerifier(NoopHostnameVerifier.INSTANCE).
+ build();
+ }
+
+ private static final X509TrustManager TRUST_ALL = new X509TrustManager()
+ {
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) { }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) { }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers()
+ {
+ return new X509Certificate[0];
+ }
+
+ };
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/HttpException.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/HttpException.java
new file mode 100644
index 0000000000..a4bf717018
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/HttpException.java
@@ -0,0 +1,102 @@
+/*
+ * 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.cli.http;
+
+import jakarta.ws.rs.core.Response;
+import java.net.URI;
+
+/**
+ * Thrown when the server responds with an error status, mirroring curl -f.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public class HttpException extends RuntimeException
+{
+
+ private static final int BODY_EXCERPT_LENGTH = 1024;
+
+ private final int status;
+ private final URI uri;
+
+ /**
+ * Constructs the exception.
+ *
+ * @param status HTTP status code
+ * @param reasonPhrase HTTP reason phrase
+ * @param uri request URI
+ * @param body response body excerpt (can be empty)
+ */
+ public HttpException(int status, String reasonPhrase, URI uri, String body)
+ {
+ super("HTTP " + status + " " + reasonPhrase + " — " + uri + (body == null || body.isBlank() ? "" : "\n" + body));
+ this.status = status;
+ this.uri = uri;
+ }
+
+ /**
+ * Throws if the response has an error status (≥ 400), otherwise returns it.
+ *
+ * @param uri request URI (for the error message)
+ * @param response response to check
+ * @return the same response
+ */
+ public static Response check(URI uri, Response response)
+ {
+ if (response.getStatus() >= 400)
+ {
+ String body = "";
+ try
+ {
+ body = response.readEntity(String.class);
+ if (body.length() > BODY_EXCERPT_LENGTH) body = body.substring(0, BODY_EXCERPT_LENGTH) + "…";
+ }
+ catch (Exception ex)
+ {
+ // body is unreadable - leave the excerpt empty
+ }
+ finally
+ {
+ response.close();
+ }
+
+ throw new HttpException(response.getStatus(), response.getStatusInfo().getReasonPhrase(), uri, body);
+ }
+
+ return response;
+ }
+
+ /**
+ * Returns the HTTP status code.
+ *
+ * @return status code
+ */
+ public int getStatus()
+ {
+ return status;
+ }
+
+ /**
+ * Returns the request URI.
+ *
+ * @return request URI
+ */
+ public URI getUri()
+ {
+ return uri;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/LDHClient.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/LDHClient.java
new file mode 100644
index 0000000000..c62a0a4dd4
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/LDHClient.java
@@ -0,0 +1,100 @@
+/*
+ * 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.cli.http;
+
+import com.atomgraph.core.MediaTypes;
+import com.atomgraph.core.client.GraphStoreClient;
+import com.atomgraph.linkeddatahub.cli.util.URIRewriter;
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.client.WebTarget;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.net.URI;
+
+/**
+ * Graph Store Protocol client for LinkedDataHub documents (direct graph identification),
+ * extended with SPARQL update over PATCH and form-encoded POST. When a proxy URI is given,
+ * every request URI has its origin rewritten to the proxy's origin, matching the
+ * --proxy handling of the bin/ shell scripts.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public class LDHClient extends GraphStoreClient
+{
+
+ /** SPARQL update media type */
+ public static final MediaType APPLICATION_SPARQL_UPDATE_TYPE = MediaType.valueOf("application/sparql-update");
+
+ private final URI proxy;
+
+ /**
+ * Constructs the client.
+ *
+ * @param client Jersey client with WebID client certificate
+ * @param mediaTypes registry of readable/writable media types
+ * @param proxy proxy URI whose origin replaces the request URI origin (optional, can be null)
+ */
+ public LDHClient(Client client, MediaTypes mediaTypes, URI proxy)
+ {
+ super(client, mediaTypes);
+ this.proxy = proxy;
+ }
+
+ @Override
+ protected WebTarget getWebTarget(URI uri)
+ {
+ return super.getWebTarget(getProxy() != null ? URIRewriter.rewrite(uri, getProxy()) : uri);
+ }
+
+ /**
+ * Patches a document with a SPARQL update.
+ *
+ * @param uri document URI
+ * @param update SPARQL update string
+ * @return response
+ */
+ public Response patch(URI uri, String update)
+ {
+ return getWebTarget(uri).request().method("PATCH", Entity.entity(update, APPLICATION_SPARQL_UPDATE_TYPE));
+ }
+
+ /**
+ * Posts a form-encoded request.
+ *
+ * @param uri target URI
+ * @param form form params
+ * @param acceptedTypes accepted response media types
+ * @return response
+ */
+ public Response postForm(URI uri, Form form, MediaType... acceptedTypes)
+ {
+ return getWebTarget(uri).request(acceptedTypes).post(Entity.form(form));
+ }
+
+ /**
+ * Returns the proxy URI, if any.
+ *
+ * @return proxy URI or null
+ */
+ public URI getProxy()
+ {
+ return proxy;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/BaseMixin.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/BaseMixin.java
new file mode 100644
index 0000000000..5b56680cf4
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/BaseMixin.java
@@ -0,0 +1,59 @@
+/*
+ * 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.cli.mixin;
+
+import java.net.URI;
+import picocli.CommandLine.Model.CommandSpec;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.ParameterException;
+
+/**
+ * Application base URI option shared by commands whose script counterpart takes -b.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public class BaseMixin
+{
+
+ @Option(names = {"-b", "--base"}, defaultValue = "${env:LDH_BASE}", paramLabel = "BASE_URI",
+ description = "Base URI of the application (env: LDH_BASE)")
+ private URI base;
+
+ /**
+ * Validates presence of the base URI and returns it.
+ *
+ * @param spec command spec used to raise usage errors
+ * @return base URI
+ */
+ public URI require(CommandSpec spec)
+ {
+ if (base == null) throw new ParameterException(spec.commandLine(), "Missing required option: '--base=BASE_URI' (or set LDH_BASE)");
+
+ return base;
+ }
+
+ /**
+ * Returns the base URI, if any.
+ *
+ * @return base URI or null
+ */
+ public URI getBase()
+ {
+ return base;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/CertAuthMixin.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/CertAuthMixin.java
new file mode 100644
index 0000000000..23ac29efaa
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/CertAuthMixin.java
@@ -0,0 +1,71 @@
+/*
+ * 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.cli.mixin;
+
+import java.nio.file.Path;
+import picocli.CommandLine.Model.CommandSpec;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.ParameterException;
+
+/**
+ * WebID client certificate options shared by all commands.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public class CertAuthMixin
+{
+
+ @Option(names = {"-f", "--cert-file"}, defaultValue = "${env:LDH_CERT_FILE}", paramLabel = "CERT_FILE",
+ description = ".p12 (PKCS12) keystore with the WebID certificate of the agent (env: LDH_CERT_FILE)")
+ private Path certFile;
+
+ @Option(names = {"-p", "--cert-password"}, defaultValue = "${env:LDH_CERT_PASSWORD}", paramLabel = "CERT_PASSWORD",
+ description = "Password of the WebID certificate (env: LDH_CERT_PASSWORD)")
+ private String certPassword;
+
+ /**
+ * Validates that both certificate options are present.
+ *
+ * @param spec command spec used to raise usage errors
+ */
+ public void validate(CommandSpec spec)
+ {
+ if (certFile == null) throw new ParameterException(spec.commandLine(), "Missing required option: '--cert-file=CERT_FILE' (or set LDH_CERT_FILE)");
+ if (certPassword == null) throw new ParameterException(spec.commandLine(), "Missing required option: '--cert-password=CERT_PASSWORD' (or set LDH_CERT_PASSWORD)");
+ }
+
+ /**
+ * Returns the keystore path.
+ *
+ * @return keystore path
+ */
+ public Path getCertFile()
+ {
+ return certFile;
+ }
+
+ /**
+ * Returns the keystore password.
+ *
+ * @return keystore password
+ */
+ public String getCertPassword()
+ {
+ return certPassword;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/ProxyMixin.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/ProxyMixin.java
new file mode 100644
index 0000000000..876a8d54a5
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/ProxyMixin.java
@@ -0,0 +1,44 @@
+/*
+ * 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.cli.mixin;
+
+import java.net.URI;
+import picocli.CommandLine.Option;
+
+/**
+ * Proxy option shared by all commands.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public class ProxyMixin
+{
+
+ @Option(names = "--proxy", defaultValue = "${env:LDH_PROXY}", paramLabel = "PROXY_URL",
+ description = "The host this request will be proxied through (optional) (env: LDH_PROXY)")
+ private URI proxy;
+
+ /**
+ * Returns the proxy URI, if any.
+ *
+ * @return proxy URI or null
+ */
+ public URI getProxy()
+ {
+ return proxy;
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/sparql/Updates.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/sparql/Updates.java
new file mode 100644
index 0000000000..cb621e377b
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/sparql/Updates.java
@@ -0,0 +1,151 @@
+/*
+ * 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.cli.sparql;
+
+import java.net.URI;
+import org.apache.jena.query.ParameterizedSparqlString;
+
+/**
+ * SPARQL update templates for the PATCH-based commands. All templates are standard SPARQL 1.1;
+ * IRIs are injected via {@link ParameterizedSparqlString} to guarantee correct escaping.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class Updates
+{
+
+ private Updates() { }
+
+ /**
+ * Adds an owl:imports statement to the ontology that is the primary topic
+ * of the given document.
+ *
+ * @param ontologyDoc ontology document URI
+ * @param importURI imported ontology URI
+ * @return SPARQL update string
+ */
+ public static String insertOntologyImport(URI ontologyDoc, URI importURI)
+ {
+ ParameterizedSparqlString pss = new ParameterizedSparqlString("""
+ PREFIX owl:
+ PREFIX foaf:
+ INSERT {
+ ?ontology owl:imports ?import .
+ }
+ WHERE {
+ ?doc foaf:primaryTopic ?ontology .
+ }
+ """);
+ pss.setIri("doc", ontologyDoc.toString());
+ pss.setIri("import", importURI.toString());
+ return pss.toString();
+ }
+
+ /**
+ * Adds a foaf:member statement to the group that is the primary topic
+ * of the given document.
+ *
+ * @param groupDoc group document URI
+ * @param agent agent URI
+ * @return SPARQL update string
+ */
+ public static String insertGroupMember(URI groupDoc, URI agent)
+ {
+ ParameterizedSparqlString pss = new ParameterizedSparqlString("""
+ PREFIX foaf:
+ INSERT {
+ ?group foaf:member ?agent .
+ }
+ WHERE {
+ ?doc foaf:primaryTopic ?group .
+ }
+ """);
+ pss.setIri("doc", groupDoc.toString());
+ pss.setIri("agent", agent.toString());
+ return pss.toString();
+ }
+
+ /**
+ * Removes a content block (or all content blocks) from a document, together with the
+ * block's own description.
+ *
+ * @param doc document URI
+ * @param block block URI, or null to remove all blocks
+ * @return SPARQL update string
+ */
+ public static String removeBlock(URI doc, URI block)
+ {
+ ParameterizedSparqlString pss = new ParameterizedSparqlString("""
+ PREFIX rdf:
+
+ DELETE
+ {
+ ?doc ?seq ?block .
+ ?block ?p ?o .
+ }
+ WHERE
+ {
+ ?doc ?seq ?block .
+ FILTER(strstarts(str(?seq), concat(str(rdf:), "_")))
+ OPTIONAL
+ {
+ ?block ?p ?o
+ }
+ }
+ """);
+ pss.setIri("doc", doc.toString());
+ if (block != null) pss.setIri("block", block.toString());
+ return pss.toString();
+ }
+
+ /**
+ * Makes all end-user application documents publicly readable and allows queries over POST,
+ * by extending the built-in public authorization.
+ *
+ * @param base end-user application base URI
+ * @param adminBase admin application base URI
+ * @return SPARQL update string
+ */
+ public static String makePublic(URI base, URI adminBase)
+ {
+ ParameterizedSparqlString pss = new ParameterizedSparqlString("""
+ PREFIX acl:
+ PREFIX def:
+ PREFIX dh:
+ PREFIX nfo:
+ PREFIX foaf:
+
+ INSERT
+ {
+ ?public acl:accessToClass def:Root, dh:Container, dh:Item, nfo:FileDataObject ;
+ acl:accessTo ?sparql .
+
+ ?sparqlPost a acl:Authorization ;
+ acl:accessTo ?sparql ;
+ acl:mode acl:Append ;
+ acl:agentClass foaf:Agent, acl:AuthenticatedAgent . # hacky way to allow queries over POST
+ }
+ WHERE
+ {}
+ """);
+ pss.setIri("public", adminBase + "acl/authorizations/public/#this");
+ pss.setIri("sparqlPost", adminBase + "acl/authorizations/public/#sparql-post");
+ pss.setIri("sparql", base + "sparql");
+ return pss.toString();
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Digests.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Digests.java
new file mode 100644
index 0000000000..dd1638251b
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Digests.java
@@ -0,0 +1,69 @@
+/*
+ * 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.cli.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+/**
+ * File digest helpers.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class Digests
+{
+
+ private Digests() { }
+
+ /**
+ * Returns the lowercase hex SHA1 digest of a file, matching shasum -a 1.
+ *
+ * @param file file path
+ * @return hex digest string
+ */
+ public static String sha1Hex(Path file)
+ {
+ try
+ {
+ MessageDigest md = MessageDigest.getInstance("SHA-1");
+
+ try (InputStream is = Files.newInputStream(file))
+ {
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = is.read(buffer)) != -1) md.update(buffer, 0, read);
+ }
+
+ return HexFormat.of().formatHex(md.digest());
+ }
+ catch (NoSuchAlgorithmException ex)
+ {
+ throw new IllegalStateException(ex);
+ }
+ catch (IOException ex)
+ {
+ throw new UncheckedIOException("Could not read file '" + file + "'", ex);
+ }
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbers.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbers.java
new file mode 100644
index 0000000000..22fcc37387
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbers.java
@@ -0,0 +1,75 @@
+/*
+ * 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.cli.util;
+
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.RDFNode;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.rdf.model.ResourceFactory;
+import org.apache.jena.rdf.model.StmtIterator;
+import org.apache.jena.vocabulary.RDF;
+
+/**
+ * RDF container membership property (rdf:_N) helpers for content blocks.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class SequenceNumbers
+{
+
+ private SequenceNumbers() { }
+
+ /**
+ * Returns the next free membership property rdf:_(max + 1) for the given subject,
+ * where max is the highest existing rdf:_N predicate (0 if none).
+ *
+ * @param model model with the subject's description
+ * @param subject resource whose membership properties are scanned
+ * @return next membership property
+ */
+ public static Property nextSequenceProperty(Model model, Resource subject)
+ {
+ String prefix = RDF.getURI() + "_";
+ int max = 0;
+
+ StmtIterator it = model.listStatements(subject, null, (RDFNode)null);
+ try
+ {
+ while (it.hasNext())
+ {
+ String uri = it.next().getPredicate().getURI();
+ if (uri.startsWith(prefix))
+ try
+ {
+ max = Math.max(max, Integer.parseInt(uri.substring(prefix.length())));
+ }
+ catch (NumberFormatException ex)
+ {
+ // not a membership property, e.g. rdf:_x
+ }
+ }
+ }
+ finally
+ {
+ it.close();
+ }
+
+ return ResourceFactory.createProperty(prefix + (max + 1));
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Slugs.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Slugs.java
new file mode 100644
index 0000000000..6d77b04ed8
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Slugs.java
@@ -0,0 +1,42 @@
+/*
+ * 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.cli.util;
+
+import java.util.Locale;
+import java.util.UUID;
+
+/**
+ * Default URI path segment generation.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class Slugs
+{
+
+ private Slugs() { }
+
+ /**
+ * Returns a random lowercase UUID slug, matching uuidgen | tr '[:upper:]' '[:lower:]'.
+ *
+ * @return slug string
+ */
+ public static String defaultSlug()
+ {
+ return UUID.randomUUID().toString().toLowerCase(Locale.ROOT);
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/URIRewriter.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/URIRewriter.java
new file mode 100644
index 0000000000..593181c035
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/URIRewriter.java
@@ -0,0 +1,104 @@
+/*
+ * 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.cli.util;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * URI manipulation helpers matching the conventions of the bin/ shell scripts.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class URIRewriter
+{
+
+ private URIRewriter() { }
+
+ /**
+ * Replaces the origin (scheme and authority) of a URI with the origin of the proxy URI,
+ * keeping the path, query and fragment.
+ *
+ * @param uri logical URI
+ * @param proxy proxy URI whose origin is substituted
+ * @return rewritten URI
+ */
+ public static URI rewrite(URI uri, URI proxy)
+ {
+ return URI.create(origin(proxy) + uri.toString().substring(origin(uri).length()));
+ }
+
+ /**
+ * Returns the origin (scheme and authority) of a URI.
+ *
+ * @param uri URI
+ * @return origin string, e.g. https://localhost:4443
+ */
+ public static String origin(URI uri)
+ {
+ if (uri.getScheme() == null || uri.getRawAuthority() == null) throw new IllegalArgumentException("URI '" + uri + "' is not absolute");
+
+ return uri.getScheme() + "://" + uri.getRawAuthority();
+ }
+
+ /**
+ * Converts an end-user application base URI to the base URI of its admin application
+ * by prefixing the host with the admin. subdomain.
+ *
+ * @param base end-user base URI
+ * @return admin base URI
+ */
+ public static URI adminBase(URI base)
+ {
+ return URI.create(base.toString().replaceFirst("://", "://admin."));
+ }
+
+ /**
+ * Percent-encodes a string as a URI path segment. All characters except RFC 3986
+ * unreserved ones are encoded, including /.
+ *
+ * @param slug path segment
+ * @return encoded path segment
+ */
+ public static String encodeSlug(String slug)
+ {
+ StringBuilder sb = new StringBuilder();
+
+ for (byte b : slug.getBytes(StandardCharsets.UTF_8))
+ {
+ char c = (char)(b & 0xFF);
+ if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
+ c == '-' || c == '.' || c == '_' || c == '~') sb.append(c);
+ else sb.append('%').append(String.format("%02X", b & 0xFF));
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Builds the URI of a child document from the parent container URI and a path segment slug.
+ *
+ * @param parent parent container URI (with trailing slash)
+ * @param slug path segment
+ * @return child document URI (with trailing slash)
+ */
+ public static URI childURI(URI parent, String slug)
+ {
+ return URI.create(parent.toString() + encodeSlug(slug) + "/");
+ }
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/A.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/A.java
new file mode 100644
index 0000000000..73dd5abd3f
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/A.java
@@ -0,0 +1,42 @@
+/*
+ * 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.cli.vocab;
+
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.ResourceFactory;
+
+/**
+ * AtomGraph Core vocabulary.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class A
+{
+
+ /** Namespace URI */
+ public static final String NS = "https://w3id.org/atomgraph/core#";
+
+ private A() { }
+
+ /** a:graphStore property */
+ public static final Property graphStore = ResourceFactory.createProperty(NS + "graphStore");
+ /** a:authUser property */
+ public static final Property authUser = ResourceFactory.createProperty(NS + "authUser");
+ /** a:authPwd property */
+ public static final Property authPwd = ResourceFactory.createProperty(NS + "authPwd");
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/AC.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/AC.java
new file mode 100644
index 0000000000..9de56512ea
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/AC.java
@@ -0,0 +1,38 @@
+/*
+ * 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.cli.vocab;
+
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.ResourceFactory;
+
+/**
+ * AtomGraph Client vocabulary.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class AC
+{
+
+ /** Namespace URI */
+ public static final String NS = "https://w3id.org/atomgraph/client#";
+
+ private AC() { }
+
+ /** ac:mode property */
+ public static final Property mode = ResourceFactory.createProperty(NS + "mode");
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/ACL.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/ACL.java
new file mode 100644
index 0000000000..e7f3ff7096
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/ACL.java
@@ -0,0 +1,60 @@
+/*
+ * 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.cli.vocab;
+
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.rdf.model.ResourceFactory;
+
+/**
+ * W3C Web Access Control vocabulary.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class ACL
+{
+
+ /** Namespace URI */
+ public static final String NS = "http://www.w3.org/ns/auth/acl#";
+
+ private ACL() { }
+
+ /** acl:Authorization class */
+ public static final Resource Authorization = ResourceFactory.createResource(NS + "Authorization");
+ /** acl:Append mode */
+ public static final Resource Append = ResourceFactory.createResource(NS + "Append");
+ /** acl:Control mode */
+ public static final Resource Control = ResourceFactory.createResource(NS + "Control");
+ /** acl:Read mode */
+ public static final Resource Read = ResourceFactory.createResource(NS + "Read");
+ /** acl:Write mode */
+ public static final Resource Write = ResourceFactory.createResource(NS + "Write");
+
+ /** acl:agent property */
+ public static final Property agent = ResourceFactory.createProperty(NS + "agent");
+ /** acl:agentClass property */
+ public static final Property agentClass = ResourceFactory.createProperty(NS + "agentClass");
+ /** acl:agentGroup property */
+ public static final Property agentGroup = ResourceFactory.createProperty(NS + "agentGroup");
+ /** acl:accessTo property */
+ public static final Property accessTo = ResourceFactory.createProperty(NS + "accessTo");
+ /** acl:accessToClass property */
+ public static final Property accessToClass = ResourceFactory.createProperty(NS + "accessToClass");
+ /** acl:mode property */
+ public static final Property mode = ResourceFactory.createProperty(NS + "mode");
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/DH.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/DH.java
new file mode 100644
index 0000000000..5829aae8e4
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/DH.java
@@ -0,0 +1,40 @@
+/*
+ * 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.cli.vocab;
+
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.rdf.model.ResourceFactory;
+
+/**
+ * Document hierarchy vocabulary.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class DH
+{
+
+ /** Namespace URI */
+ public static final String NS = "https://www.w3.org/ns/ldt/document-hierarchy#";
+
+ private DH() { }
+
+ /** dh:Item class */
+ public static final Resource Item = ResourceFactory.createResource(NS + "Item");
+ /** dh:Container class */
+ public static final Resource Container = ResourceFactory.createResource(NS + "Container");
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/LDH.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/LDH.java
new file mode 100644
index 0000000000..d256327514
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/LDH.java
@@ -0,0 +1,68 @@
+/*
+ * 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.cli.vocab;
+
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.rdf.model.ResourceFactory;
+
+/**
+ * LinkedDataHub vocabulary.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class LDH
+{
+
+ /** Namespace URI */
+ public static final String NS = "https://w3id.org/atomgraph/linkeddatahub#";
+
+ private LDH() { }
+
+ /** ldh:Object class */
+ public static final Resource Object = ResourceFactory.createResource(NS + "Object");
+ /** ldh:XHTML class */
+ public static final Resource XHTML = ResourceFactory.createResource(NS + "XHTML");
+ /** ldh:View class */
+ public static final Resource View = ResourceFactory.createResource(NS + "View");
+ /** ldh:ResultSetChart class */
+ public static final Resource ResultSetChart = ResourceFactory.createResource(NS + "ResultSetChart");
+ /** ldh:CSVImport class */
+ public static final Resource CSVImport = ResourceFactory.createResource(NS + "CSVImport");
+ /** ldh:RDFImport class */
+ public static final Resource RDFImport = ResourceFactory.createResource(NS + "RDFImport");
+ /** ldh:MissingPropertyValue constraint class */
+ public static final Resource MissingPropertyValue = ResourceFactory.createResource(NS + "MissingPropertyValue");
+ /** ldh:ChildrenView resource */
+ public static final Resource ChildrenView = ResourceFactory.createResource(NS + "ChildrenView");
+ /** ldh:SelectChildren query resource */
+ public static final Resource SelectChildren = ResourceFactory.createResource(NS + "SelectChildren");
+
+ /** ldh:service property */
+ public static final Property service = ResourceFactory.createProperty(NS + "service");
+ /** ldh:file property */
+ public static final Property file = ResourceFactory.createProperty(NS + "file");
+ /** ldh:delimiter property */
+ public static final Property delimiter = ResourceFactory.createProperty(NS + "delimiter");
+ /** ldh:chartType property */
+ public static final Property chartType = ResourceFactory.createProperty(NS + "chartType");
+ /** ldh:categoryVarName property */
+ public static final Property categoryVarName = ResourceFactory.createProperty(NS + "categoryVarName");
+ /** ldh:seriesVarName property */
+ public static final Property seriesVarName = ResourceFactory.createProperty(NS + "seriesVarName");
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/NFO.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/NFO.java
new file mode 100644
index 0000000000..de6a53c654
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/NFO.java
@@ -0,0 +1,42 @@
+/*
+ * 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.cli.vocab;
+
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.rdf.model.ResourceFactory;
+
+/**
+ * NEPOMUK File Ontology vocabulary.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class NFO
+{
+
+ /** Namespace URI */
+ public static final String NS = "http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#";
+
+ private NFO() { }
+
+ /** nfo:FileDataObject class */
+ public static final Resource FileDataObject = ResourceFactory.createResource(NS + "FileDataObject");
+
+ /** nfo:fileName property */
+ public static final Property fileName = ResourceFactory.createProperty(NS + "fileName");
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SD.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SD.java
new file mode 100644
index 0000000000..4bec9d26fe
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SD.java
@@ -0,0 +1,50 @@
+/*
+ * 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.cli.vocab;
+
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.rdf.model.ResourceFactory;
+
+/**
+ * SPARQL 1.1 Service Description vocabulary.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class SD
+{
+
+ /** Namespace URI */
+ public static final String NS = "http://www.w3.org/ns/sparql-service-description#";
+
+ private SD() { }
+
+ /** sd:Service class */
+ public static final Resource Service = ResourceFactory.createResource(NS + "Service");
+ /** sd:SPARQL11Query language */
+ public static final Resource SPARQL11Query = ResourceFactory.createResource(NS + "SPARQL11Query");
+ /** sd:SPARQL11Update language */
+ public static final Resource SPARQL11Update = ResourceFactory.createResource(NS + "SPARQL11Update");
+
+ /** sd:endpoint property */
+ public static final Property endpoint = ResourceFactory.createProperty(NS + "endpoint");
+ /** sd:supportedLanguage property */
+ public static final Property supportedLanguage = ResourceFactory.createProperty(NS + "supportedLanguage");
+ /** sd:name property */
+ public static final Property name = ResourceFactory.createProperty(NS + "name");
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SP.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SP.java
new file mode 100644
index 0000000000..6634441f9b
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SP.java
@@ -0,0 +1,46 @@
+/*
+ * 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.cli.vocab;
+
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.rdf.model.ResourceFactory;
+
+/**
+ * SPIN SPARQL syntax vocabulary.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class SP
+{
+
+ /** Namespace URI */
+ public static final String NS = "http://spinrdf.org/sp#";
+
+ private SP() { }
+
+ /** sp:Construct class */
+ public static final Resource Construct = ResourceFactory.createResource(NS + "Construct");
+ /** sp:Select class */
+ public static final Resource Select = ResourceFactory.createResource(NS + "Select");
+
+ /** sp:text property */
+ public static final Property text = ResourceFactory.createProperty(NS + "text");
+ /** sp:arg1 property */
+ public static final Property arg1 = ResourceFactory.createProperty(NS + "arg1");
+
+}
diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SPIN.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SPIN.java
new file mode 100644
index 0000000000..a4cbec33d3
--- /dev/null
+++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SPIN.java
@@ -0,0 +1,42 @@
+/*
+ * 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.cli.vocab;
+
+import org.apache.jena.rdf.model.Property;
+import org.apache.jena.rdf.model.ResourceFactory;
+
+/**
+ * SPIN modeling vocabulary.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public final class SPIN
+{
+
+ /** Namespace URI */
+ public static final String NS = "http://spinrdf.org/spin#";
+
+ private SPIN() { }
+
+ /** spin:query property */
+ public static final Property query = ResourceFactory.createProperty(NS + "query");
+ /** spin:constructor property */
+ public static final Property constructor = ResourceFactory.createProperty(NS + "constructor");
+ /** spin:constraint property */
+ public static final Property constraint = ResourceFactory.createProperty(NS + "constraint");
+
+}
diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/CommandParsingTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/CommandParsingTest.java
new file mode 100644
index 0000000000..f42ccf76d4
--- /dev/null
+++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/CommandParsingTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.cli;
+
+import java.io.PrintWriter;
+import java.io.Writer;
+import java.net.URI;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import picocli.CommandLine;
+import picocli.CommandLine.ParseResult;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for the command tree and argument parsing.
+ */
+public class CommandParsingTest
+{
+
+ static CommandLine commandLine()
+ {
+ CommandLine cmd = new CommandLine(new LDH());
+ cmd.setOut(new PrintWriter(Writer.nullWriter()));
+ cmd.setErr(new PrintWriter(Writer.nullWriter()));
+ return cmd;
+ }
+
+ @Test
+ public void commandTreeMirrorsScriptLayout()
+ {
+ CommandLine root = commandLine();
+
+ List.of("get", "post", "put", "patch", "delete", "create-item", "create-container",
+ "add-view", "add-construct", "add-select", "add-result-set-chart", "add-file", "add-generic-service",
+ "admin", "content", "imports").
+ forEach(name -> assertTrue(root.getSubcommands().containsKey(name), name));
+
+ CommandLine admin = root.getSubcommands().get("admin");
+ List.of("ontologies", "acl", "packages", "clear-ontology", "add-ontology-import").
+ forEach(name -> assertTrue(admin.getSubcommands().containsKey(name), name));
+
+ List.of("create-ontology", "import-ontology", "add-class", "add-constructor", "add-select",
+ "add-property-constraint", "add-restriction").
+ forEach(name -> assertTrue(admin.getSubcommands().get("ontologies").getSubcommands().containsKey(name), name));
+
+ List.of("create-group", "create-authorization", "add-agent-to-group", "make-public").
+ forEach(name -> assertTrue(admin.getSubcommands().get("acl").getSubcommands().containsKey(name), name));
+
+ List.of("install-package", "uninstall-package").
+ forEach(name -> assertTrue(admin.getSubcommands().get("packages").getSubcommands().containsKey(name), name));
+
+ List.of("add-object-block", "add-xhtml-block", "remove-block").
+ forEach(name -> assertTrue(root.getSubcommands().get("content").getSubcommands().containsKey(name), name));
+
+ List.of("add-csv-import", "add-rdf-import", "import-csv", "import-rdf").
+ forEach(name -> assertTrue(root.getSubcommands().get("imports").getSubcommands().containsKey(name), name));
+ }
+
+ @Test
+ public void missingRequiredOptionIsUsageError()
+ {
+ assertEquals(CommandLine.ExitCode.USAGE, commandLine().execute("create-item", "--container", "https://localhost:4443/some/"));
+ }
+
+ @Test
+ public void unknownOptionIsUsageError()
+ {
+ assertEquals(CommandLine.ExitCode.USAGE, commandLine().execute("get", "--bogus"));
+ }
+
+ @Test
+ public void bareGroupCommandIsUsageError()
+ {
+ assertEquals(CommandLine.ExitCode.USAGE, commandLine().execute("admin"));
+ assertEquals(CommandLine.ExitCode.USAGE, commandLine().execute("admin", "acl"));
+ }
+
+ @Test
+ public void repeatableOptionsAccumulate()
+ {
+ ParseResult parseResult = commandLine().parseArgs("admin", "acl", "create-group",
+ "-f", "cert.p12", "-p", "secret", "-b", "https://admin.localhost:4443/",
+ "--name", "Editors",
+ "--member", "https://localhost:4443/acl/agents/a/#this",
+ "--member", "https://localhost:4443/acl/agents/b/#this");
+
+ ParseResult createGroup = parseResult.subcommand().subcommand().subcommand();
+ List members = createGroup.matchedOption("--member").getValue();
+ assertEquals(2, members.size());
+ }
+
+ @Test
+ public void missingCertOptionsFailValidationAtExecutionTime()
+ {
+ // cert options have env-var defaults, so they are validated at execution time, not parse time
+ assertNotNull(commandLine().parseArgs("delete", "https://localhost:4443/some/"));
+ assertEquals(CommandLine.ExitCode.USAGE, commandLine().execute("delete", "https://localhost:4443/some/"));
+ }
+
+}
diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/AddFileMultiPartTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/AddFileMultiPartTest.java
new file mode 100644
index 0000000000..a40c4ae8f7
--- /dev/null
+++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/AddFileMultiPartTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.cli.command;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.glassfish.jersey.media.multipart.FormDataBodyPart;
+import org.glassfish.jersey.media.multipart.FormDataMultiPart;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests the RDF/POST multipart encoding of {@link AddFile}: the field order is positional,
+ * each pu must immediately precede its ol/ou value.
+ */
+public class AddFileMultiPartTest
+{
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ public void fieldOrderIsPositional() throws Exception
+ {
+ Path file = tempDir.resolve("data.csv");
+ Files.writeString(file, "a,b\n1,2\n");
+
+ try (FormDataMultiPart multiPart = AddFile.buildMultiPart(file, "text/csv", "Data", null))
+ {
+ List names = multiPart.getBodyParts().stream().
+ map(part -> ((FormDataBodyPart)part).getName()).
+ toList();
+
+ assertEquals(List.of("rdf", "sb", "pu", "ol", "pu", "ol", "pu", "ou"), names);
+ assertEquals("text/csv", multiPart.getBodyParts().get(3).getMediaType().toString());
+ }
+ }
+
+ @Test
+ public void descriptionAppendsTrailingPair() throws Exception
+ {
+ Path file = tempDir.resolve("data.csv");
+ Files.writeString(file, "a,b\n1,2\n");
+
+ try (FormDataMultiPart multiPart = AddFile.buildMultiPart(file, "text/csv", "Data", "Description"))
+ {
+ List names = multiPart.getBodyParts().stream().
+ map(part -> ((FormDataBodyPart)part).getName()).
+ toList();
+
+ assertEquals(List.of("rdf", "sb", "pu", "ol", "pu", "ol", "pu", "ou", "pu", "ol"), names);
+ }
+ }
+
+}
diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/ModelBuildersTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/ModelBuildersTest.java
new file mode 100644
index 0000000000..30a4b84cc3
--- /dev/null
+++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/ModelBuildersTest.java
@@ -0,0 +1,240 @@
+/*
+ * 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.cli.command;
+
+import com.atomgraph.linkeddatahub.cli.command.admin.acl.CreateAuthorization;
+import com.atomgraph.linkeddatahub.cli.command.admin.acl.CreateGroup;
+import com.atomgraph.linkeddatahub.cli.command.admin.ontologies.CreateOntology;
+import com.atomgraph.linkeddatahub.cli.command.content.AddXHTMLBlock;
+import com.atomgraph.linkeddatahub.cli.command.imports.AddCSVImport;
+import com.atomgraph.linkeddatahub.cli.command.imports.AddRDFImport;
+import com.atomgraph.linkeddatahub.cli.vocab.ACL;
+import com.atomgraph.linkeddatahub.cli.vocab.SP;
+import java.io.StringReader;
+import java.net.URI;
+import java.util.List;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.riot.Lang;
+import org.apache.jena.riot.RDFParser;
+import org.apache.jena.vocabulary.RDF;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Compares command model builders against the Turtle bodies of the original shell scripts.
+ */
+public class ModelBuildersTest
+{
+
+ private static final URI TARGET = URI.create("https://localhost:4443/some/");
+ private static final String PREFIXES = """
+ @prefix dh: .
+ @prefix ldh: .
+ @prefix rdf: .
+ @prefix dct: .
+ @prefix spin: .
+ @prefix sp: .
+ @prefix ac: .
+ @prefix acl: .
+ @prefix sd: .
+ @prefix owl: .
+ @prefix rdfs: .
+ @prefix foaf: .
+ """;
+
+ static Model parse(String turtle)
+ {
+ Model model = ModelFactory.createDefaultModel();
+ RDFParser.create().source(new StringReader(PREFIXES + turtle)).lang(Lang.TURTLE).base(TARGET.toString()).parse(model);
+ return model;
+ }
+
+ static void assertIsomorphic(Model expected, Model actual)
+ {
+ assertTrue(actual.isIsomorphicWith(expected),
+ "Expected:\n" + expected.toString() + "\nActual:\n" + actual.toString());
+ }
+
+ @Test
+ public void createItem()
+ {
+ URI doc = URI.create("https://localhost:4443/some/my-item/");
+
+ assertIsomorphic(parse("""
+ a dh:Item ;
+ dct:title "My item" ;
+ dct:description "Desc" .
+ """),
+ CreateItem.buildModel(doc, "My item", "Desc"));
+ }
+
+ @Test
+ public void createContainerDefaultChildrenView()
+ {
+ assertIsomorphic(parse("""
+ <> a dh:Container ;
+ dct:title "Some" ;
+ rdf:_1 [ a ldh:Object ; rdf:value ldh:ChildrenView ] .
+ """),
+ CreateContainer.buildModel(TARGET, "Some", null, null, null));
+ }
+
+ @Test
+ public void createContainerWithMode()
+ {
+ assertIsomorphic(parse("""
+ <> a dh:Container ;
+ dct:title "Some" ;
+ rdf:_1 [ a ldh:Object ; rdf:value [ a ldh:View ; spin:query ldh:SelectChildren ; ac:mode ] ] .
+ """),
+ CreateContainer.buildModel(TARGET, "Some", null, null, URI.create("https://w3id.org/atomgraph/client#GridMode")));
+ }
+
+ @Test
+ public void createContainerWithBlock()
+ {
+ assertIsomorphic(parse("""
+ <> a dh:Container ;
+ dct:title "Some" ;
+ rdf:_1 .
+ """),
+ CreateContainer.buildModel(TARGET, "Some", null, URI.create("https://localhost:4443/some/#block"), null));
+ }
+
+ @Test
+ public void addViewWithURIAndMode()
+ {
+ assertIsomorphic(parse("""
+ <#view> a ldh:View ;
+ spin:query ;
+ dct:title "View" ;
+ ac:mode .
+ """),
+ AddView.buildModel(TARGET, "#view", URI.create("https://localhost:4443/queries/q/#this"), "View", null,
+ URI.create("https://w3id.org/atomgraph/client#GridMode")));
+ }
+
+ @Test
+ public void addConstructQuery()
+ {
+ assertIsomorphic(parse("""
+ _:q a sp:Construct ;
+ dct:title "Query" ;
+ sp:text \"""CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }\""" ;
+ ldh:service .
+ """),
+ AddConstruct.buildModel(TARGET, null, SP.Construct, "Query", "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }",
+ URI.create("https://localhost:4443/services/s/#this"), null));
+ }
+
+ @Test
+ public void createOntology()
+ {
+ URI doc = URI.create("https://admin.localhost:4443/ontologies/my-ont/");
+
+ assertIsomorphic(parse("""
+ @base .
+ _:ontology a owl:Ontology ;
+ rdfs:label "My ontology" ;
+ rdfs:comment "Comment" .
+ <> a dh:Item ;
+ foaf:primaryTopic _:ontology ;
+ dct:title "My ontology" .
+ """),
+ CreateOntology.buildModel(doc, null, "My ontology", "Comment"));
+ }
+
+ @Test
+ public void createGroup()
+ {
+ URI doc = URI.create("https://admin.localhost:4443/acl/groups/editors/");
+
+ assertIsomorphic(parse("""
+ @base .
+ _:group a foaf:Group ;
+ foaf:name "Editors" ;
+ foaf:member , .
+ <> a dh:Item ;
+ foaf:primaryTopic _:group ;
+ dct:title "Editors" .
+ """),
+ CreateGroup.buildModel(doc, null, "Editors", null,
+ List.of(URI.create("https://localhost:4443/acl/agents/a/#this"), URI.create("https://localhost:4443/acl/agents/b/#this"))));
+ }
+
+ @Test
+ public void createAuthorization()
+ {
+ URI doc = URI.create("https://admin.localhost:4443/acl/authorizations/auth/");
+
+ assertIsomorphic(parse("""
+ @base .
+ _:auth a acl:Authorization ;
+ rdfs:label "Auth" ;
+ acl:agent ;
+ acl:accessTo ;
+ acl:mode acl:Read, acl:Write .
+ <> a dh:Item ;
+ foaf:primaryTopic _:auth ;
+ dct:title "Auth" .
+ """),
+ CreateAuthorization.buildModel(doc, null, "Auth", null,
+ List.of(URI.create("https://localhost:4443/acl/agents/a/#this")), List.of(), List.of(),
+ List.of(URI.create("https://localhost:4443/some/")), List.of(),
+ List.of(ACL.Read, ACL.Write)));
+ }
+
+ @Test
+ public void addCSVImport()
+ {
+ assertIsomorphic(parse("""
+ _:import a ldh:CSVImport ;
+ dct:title "Cities" ;
+ spin:query ;
+ ldh:file ;
+ ldh:delimiter "," .
+ """),
+ AddCSVImport.buildModel(TARGET, null, "Cities", URI.create("https://localhost:4443/some/#query"),
+ URI.create("https://localhost:4443/uploads/abc"), ",", null));
+ }
+
+ @Test
+ public void addRDFImportWithGraph()
+ {
+ assertIsomorphic(parse("""
+ <#import> a ldh:RDFImport ;
+ dct:title "Data" ;
+ ldh:file ;
+ sd:name .
+ """),
+ AddRDFImport.buildModel(TARGET, "#import", "Data", URI.create("https://localhost:4443/uploads/abc"),
+ null, URI.create("https://localhost:4443/graphs/g/"), null));
+ }
+
+ @Test
+ public void addXHTMLBlock()
+ {
+ assertIsomorphic(parse("""
+ <> rdf:_4 _:block .
+ _:block a ldh:XHTML ;
+ rdf:value "Hello
"^^rdf:XMLLiteral .
+ """),
+ AddXHTMLBlock.buildModel(TARGET, RDF.li(4), null, "Hello
", null, null));
+ }
+
+}
diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/ClientFactoryTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/ClientFactoryTest.java
new file mode 100644
index 0000000000..9fd10ba3d6
--- /dev/null
+++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/ClientFactoryTest.java
@@ -0,0 +1,56 @@
+/*
+ * 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.cli.http;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests for {@link ClientFactory}.
+ */
+public class ClientFactoryTest
+{
+
+ static Path keyStorePath() throws Exception
+ {
+ return Paths.get(ClientFactoryTest.class.getResource("/test-keystore.p12").toURI());
+ }
+
+ @Test
+ public void createsClientFromPKCS12Keystore() throws Exception
+ {
+ assertNotNull(ClientFactory.createClient(keyStorePath(), "changeit"));
+ }
+
+ @Test
+ public void failsCleanlyOnWrongPassword() throws Exception
+ {
+ Path keyStore = keyStorePath();
+
+ assertThrows(IllegalArgumentException.class, () -> ClientFactory.createClient(keyStore, "wrong"));
+ }
+
+ @Test
+ public void failsCleanlyOnMissingFile()
+ {
+ assertThrows(IllegalArgumentException.class, () -> ClientFactory.createClient(Path.of("/nonexistent.p12"), "changeit"));
+ }
+
+}
diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/sparql/UpdatesTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/sparql/UpdatesTest.java
new file mode 100644
index 0000000000..f7608273a5
--- /dev/null
+++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/sparql/UpdatesTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.cli.sparql;
+
+import java.net.URI;
+import org.apache.jena.query.Syntax;
+import org.apache.jena.update.UpdateFactory;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for {@link Updates}: every template must be valid standard SPARQL 1.1.
+ */
+public class UpdatesTest
+{
+
+ private static final URI DOC = URI.create("https://localhost:4443/some/");
+ private static final URI BASE = URI.create("https://localhost:4443/");
+ private static final URI ADMIN_BASE = URI.create("https://admin.localhost:4443/");
+
+ @Test
+ public void insertOntologyImportIsValidSPARQL11()
+ {
+ String update = Updates.insertOntologyImport(DOC, URI.create("https://example.org/ontology#"));
+
+ assertDoesNotThrow(() -> UpdateFactory.create(update, Syntax.syntaxSPARQL_11));
+ assertTrue(update.contains(""));
+ }
+
+ @Test
+ public void insertGroupMemberIsValidSPARQL11()
+ {
+ String update = Updates.insertGroupMember(DOC, URI.create("https://localhost:4443/agents/x/#this"));
+
+ assertDoesNotThrow(() -> UpdateFactory.create(update, Syntax.syntaxSPARQL_11));
+ assertTrue(update.contains(""));
+ }
+
+ @Test
+ public void removeBlockWithoutBlockKeepsVariable()
+ {
+ String update = Updates.removeBlock(DOC, null);
+
+ assertDoesNotThrow(() -> UpdateFactory.create(update, Syntax.syntaxSPARQL_11));
+ assertTrue(update.contains("?block"));
+ }
+
+ @Test
+ public void removeBlockWithBlockInjectsIRI()
+ {
+ String update = Updates.removeBlock(DOC, URI.create("https://localhost:4443/some/#block"));
+
+ assertDoesNotThrow(() -> UpdateFactory.create(update, Syntax.syntaxSPARQL_11));
+ assertTrue(update.contains(""));
+ assertFalse(update.contains("?block"));
+ }
+
+ @Test
+ public void makePublicIsValidSPARQL11()
+ {
+ String update = Updates.makePublic(BASE, ADMIN_BASE);
+
+ assertDoesNotThrow(() -> UpdateFactory.create(update, Syntax.syntaxSPARQL_11));
+ assertTrue(update.contains(""));
+ assertTrue(update.contains(""));
+ assertTrue(update.contains(""));
+ }
+
+}
diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbersTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbersTest.java
new file mode 100644
index 0000000000..275030c711
--- /dev/null
+++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbersTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.cli.util;
+
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.vocabulary.RDF;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests for {@link SequenceNumbers}.
+ */
+public class SequenceNumbersTest
+{
+
+ private static final String DOC = "https://localhost:4443/some/";
+
+ @Test
+ public void returnsFirstMembershipPropertyOnEmptyModel()
+ {
+ Model model = ModelFactory.createDefaultModel();
+
+ assertEquals(RDF.li(1), SequenceNumbers.nextSequenceProperty(model, model.createResource(DOC)));
+ }
+
+ @Test
+ public void returnsMaxPlusOneWithGaps()
+ {
+ Model model = ModelFactory.createDefaultModel();
+ Resource doc = model.createResource(DOC);
+ doc.addProperty(RDF.li(1), model.createResource());
+ doc.addProperty(RDF.li(2), model.createResource());
+ doc.addProperty(RDF.li(7), model.createResource());
+
+ assertEquals(RDF.li(8), SequenceNumbers.nextSequenceProperty(model, doc));
+ }
+
+ @Test
+ public void ignoresOtherSubjects()
+ {
+ Model model = ModelFactory.createDefaultModel();
+ model.createResource("https://localhost:4443/other/").addProperty(RDF.li(5), model.createResource());
+
+ assertEquals(RDF.li(1), SequenceNumbers.nextSequenceProperty(model, model.createResource(DOC)));
+ }
+
+ @Test
+ public void ignoresNonNumericSuffixes()
+ {
+ Model model = ModelFactory.createDefaultModel();
+ Resource doc = model.createResource(DOC);
+ doc.addProperty(model.createProperty(RDF.getURI() + "_x"), model.createResource());
+ doc.addProperty(RDF.li(3), model.createResource());
+
+ assertEquals(RDF.li(4), SequenceNumbers.nextSequenceProperty(model, doc));
+ }
+
+}
diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/URIRewriterTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/URIRewriterTest.java
new file mode 100644
index 0000000000..222a90863d
--- /dev/null
+++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/URIRewriterTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.cli.util;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests for {@link URIRewriter}.
+ */
+public class URIRewriterTest
+{
+
+ @Test
+ public void rewriteReplacesOriginKeepingPathQueryFragment()
+ {
+ assertEquals(URI.create("https://localhost:8443/a%20b/c/?d=e#f"),
+ URIRewriter.rewrite(URI.create("https://linkeddatahub.com/a%20b/c/?d=e#f"), URI.create("https://localhost:8443")));
+ }
+
+ @Test
+ public void rewriteIgnoresProxyPath()
+ {
+ assertEquals(URI.create("https://localhost:8443/some/"),
+ URIRewriter.rewrite(URI.create("https://linkeddatahub.com:4443/some/"), URI.create("https://localhost:8443/ignored/")));
+ }
+
+ @Test
+ public void rewriteRejectsRelativeURI()
+ {
+ assertThrows(IllegalArgumentException.class,
+ () -> URIRewriter.rewrite(URI.create("/relative/path"), URI.create("https://localhost:8443")));
+ }
+
+ @Test
+ public void adminBasePrefixesHostWithAdminSubdomain()
+ {
+ assertEquals(URI.create("https://admin.localhost:4443/"), URIRewriter.adminBase(URI.create("https://localhost:4443/")));
+ }
+
+ @Test
+ public void encodeSlugKeepsUnreservedCharacters()
+ {
+ assertEquals("abc-._~123", URIRewriter.encodeSlug("abc-._~123"));
+ }
+
+ @Test
+ public void encodeSlugEncodesReservedAndNonASCII()
+ {
+ assertEquals("a%20b%2F%C4%87", URIRewriter.encodeSlug("a b/ć"));
+ }
+
+ @Test
+ public void childURIAppendsEncodedSlugAndSlash()
+ {
+ assertEquals(URI.create("https://localhost:4443/some/my%20item/"),
+ URIRewriter.childURI(URI.create("https://localhost:4443/some/"), "my item"));
+ }
+
+}
diff --git a/cli/src/test/resources/test-keystore.p12 b/cli/src/test/resources/test-keystore.p12
new file mode 100644
index 0000000000..f50715e253
Binary files /dev/null and b/cli/src/test/resources/test-keystore.p12 differ
diff --git a/docker-compose.yml b/docker-compose.yml
index 02b5f52033..dd1883586b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -107,6 +107,10 @@ services:
fuseki-admin:
image: atomgraph/fuseki:6.1.0
user: root # otherwise fuseki user does not have permissions to the mounted folder which is owner by root
+ mem_limit: 1536m # leave headroom above heap for TDB mmap/native memory
+ restart: on-failure
+ environment:
+ - JAVA_OPTIONS=-Xmx768m -Xms768m
expose:
- 3030
volumes:
@@ -116,6 +120,10 @@ services:
fuseki-end-user:
image: atomgraph/fuseki:6.1.0
user: root # otherwise the fuseki user does not have permissions to the mounted folder which is owner by root
+ mem_limit: 3072m # leave headroom above heap for TDB mmap/native memory
+ restart: on-failure
+ environment:
+ - JAVA_OPTIONS=-Xmx1536m -Xms1536m
expose:
- 3030
volumes:
@@ -384,28 +392,24 @@ configs:
return (pass);
}
- if (req.http.Client-Cert) {
- # Authenticated HTML is user-specific → never cache
- if (req.http.Accept ~ "text/html" ||
- req.http.Accept ~ "application/xhtml+xml") {
- return (pass);
- }
-
- # Conditional requests must reach backend for validation
- if (req.http.If-Match || req.http.If-None-Match ||
- req.http.If-Modified-Since || req.http.If-Unmodified-Since) {
- return (pass);
- }
-
- # /access endpoint returns agent-specific group memberships
- if (req.url ~ "^/access") {
- return (pass);
- }
+ # Delegated requests carry user identity in the On-Behalf-Of header.
+ # The backend response echoes that identity in the Link header
+ # (acl#agent). The cache key does not include the asserted identity,
+ # so caching would let a later anonymous request to the same
+ # URL+Accept read back the previous agent's WebID and ACL grant.
+ if (req.http.On-Behalf-Of) {
+ return (pass);
+ }
- # SPARQL referencing /acl/agents/ depends on agent identity → don't cache
- if (req.url ~ "%2Facl%2Fagents%2F") {
- return (pass);
- }
+ # Authenticated responses get acl#agent stamped into the Link header by
+ # the backend, regardless of representation (HTML, RDF/XML, Turtle,
+ # JSON-LD, SPARQL results, …). The URL-keyed cache slot ignores the
+ # asserting identity, so any such response would leak to anonymous
+ # readers of the same URL. /static/* is the only safely-shared path —
+ # it's served by Tomcat's default servlet (web.xml:365-371), bypasses
+ # Jersey, and carries no identity-bearing headers.
+ if (req.http.Client-Cert && req.url !~ "^/static/") {
+ return (pass);
}
if (req.http.Cookie) {
diff --git a/http-tests/document-hierarchy/GET-namespace-forClass-rdfs.sh b/http-tests/document-hierarchy/GET-namespace-forClass-rdfs.sh
new file mode 100644
index 0000000000..e363dfbcf4
--- /dev/null
+++ b/http-tests/document-hierarchy/GET-namespace-forClass-rdfs.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL"
+initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL"
+purge_cache "$END_USER_VARNISH_SERVICE"
+purge_cache "$ADMIN_VARNISH_SERVICE"
+purge_cache "$FRONTEND_VARNISH_SERVICE"
+
+# sp:Describe is declared only as rdfs:Class (not owl:Class) in sp.ttl.
+# OntologyFilter must promote rdfs:Class to owl:Class during materialization so
+# that OWL2 profiles recognise third-party vocab terms and return their SPIN constructors.
+
+response=$(curl -k -f -s \
+ -G \
+ -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
+ -H "Accept: application/rdf+xml" \
+ --data-urlencode "forClass=http://spinrdf.org/sp#Describe" \
+ "${END_USER_BASE_URL}ns")
+
+# response must be non-empty: sp:Describe must be recognised as an OntClass
+echo "$response" | grep -q "http://spinrdf.org/sp#Describe"
diff --git a/http-tests/document-hierarchy/PATCH-blank-node-skolemized.sh b/http-tests/document-hierarchy/PATCH-blank-node-skolemized.sh
new file mode 100755
index 0000000000..fec9f83436
--- /dev/null
+++ b/http-tests/document-hierarchy/PATCH-blank-node-skolemized.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL"
+initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL"
+purge_cache "$END_USER_VARNISH_SERVICE"
+purge_cache "$ADMIN_VARNISH_SERVICE"
+purge_cache "$FRONTEND_VARNISH_SERVICE"
+
+# add agent to the writers group
+
+add-agent-to-group.sh \
+ -f "$OWNER_CERT_FILE" \
+ -p "$OWNER_CERT_PWD" \
+ --agent "$AGENT_URI" \
+ "${ADMIN_BASE_URL}acl/groups/writers/"
+
+# PATCH the root with an INSERT that introduces a blank node.
+# Use rdf:_99 to avoid colliding with existing rdf:_1..rdf:_8 in the test dataset.
+# Expected: 204 No Content; the blank node is skolemized to a hash URI before persisting.
+
+update=$(cat <
+PREFIX dct:
+
+INSERT
+{
+ <${END_USER_BASE_URL}> rdf:_99 _:bnode0 .
+ _:bnode0 dct:title "Blank node title"
+}
+WHERE
+{}
+EOF
+)
+
+curl -k -w "%{http_code}\n" -o /dev/null -f -s \
+ -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
+ -X PATCH \
+ -H "Content-Type: application/sparql-update" \
+ "$END_USER_BASE_URL" \
+ --data-binary "$update" \
+| grep -q "$STATUS_NO_CONTENT"
+
+# fetch the persisted graph and verify the blank node was skolemized to a hash URI
+
+response=$(curl -k -f -s -G \
+ -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
+ -H "Accept: application/n-triples" \
+ "$END_USER_BASE_URL")
+
+rdf_99_line=$(echo "$response" | grep -E "^<${END_USER_BASE_URL}> " || true)
+
+[ -n "$rdf_99_line" ] || exit 1
+
+# object of rdf:_99 must be a URI (<...>), not a blank node label (_:...)
+! echo "$rdf_99_line" | grep -qE '_:[A-Za-z0-9]+ \.$'
+echo "$rdf_99_line" | grep -qE '<\S+> \.$'
diff --git a/http-tests/proxy/GET-proxied-no-cache-poisoning.sh b/http-tests/proxy/GET-proxied-no-cache-poisoning.sh
new file mode 100755
index 0000000000..89337d2383
--- /dev/null
+++ b/http-tests/proxy/GET-proxied-no-cache-poisoning.sh
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Regression: ProxyRequestFilter's server-side fetch attaches On-Behalf-Of
+# (via WebIDDelegationFilter), and the backend response carries the asserted
+# agent's WebID in the Link header (acl#agent). varnish-frontend must not
+# cache that response under a URL-keyed entry — otherwise a subsequent
+# anonymous request to the same URL+Accept replays the cached 200 and reads
+# back the previous agent's identity (and inherits whatever ACL grant they
+# had).
+
+purge_cache "$END_USER_VARNISH_SERVICE"
+purge_cache "$ADMIN_VARNISH_SERVICE"
+purge_cache "$FRONTEND_VARNISH_SERVICE"
+
+# Step A: authenticated owner fires a proxy request from the end-user
+# dataspace to the admin dataspace. This triggers WebIDDelegationFilter →
+# On-Behalf-Of on the server-side hop into varnish-frontend.
+
+curl -k -f -s -o /dev/null \
+ -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
+ -G \
+ -H 'Accept: application/rdf+xml' \
+ --data-urlencode "uri=${ADMIN_BASE_URL}" \
+ "${END_USER_BASE_URL}"
+
+# Step B: anonymous direct request to the admin URL with the same Accept.
+# If the cache was poisoned in Step A, this returns 200 with the owner's
+# WebID in the Link header. Expected after the fix: varnish-frontend should
+# pass on On-Behalf-Of and store nothing, so this goes to the backend
+# anonymously and gets 403.
+
+response=$(curl -k -s -i -H 'Accept: application/rdf+xml' "${ADMIN_BASE_URL}")
+
+status=$(printf '%s\n' "$response" | awk 'NR==1{print $2}' | tr -d '\r')
+link_leak=$(printf '%s\n' "$response" | tr -d '\r' | grep -i '^link:' | grep -c 'acl#agent' || true)
+
+if [ "$status" != "$STATUS_FORBIDDEN" ]; then
+ echo "Step B: expected $STATUS_FORBIDDEN, got $status"
+ exit 1
+fi
+
+if [ "$link_leak" != "0" ]; then
+ echo "Step B: anonymous response leaks acl#agent (cache poisoning)"
+ exit 1
+fi
+
+# Step C: authenticated owner fetches the admin URL directly with cert at TLS,
+# Accept: application/rdf+xml. The Client-Cert header reaches varnish-frontend
+# (nginx-forwarded). The backend stamps acl#agent into the Link header for the
+# authenticated 200. varnish-frontend must NOT cache this response — its hash
+# key ignores identity, so a subsequent anonymous request would replay the 200.
+
+purge_cache "$FRONTEND_VARNISH_SERVICE"
+
+curl -k -f -s -o /dev/null \
+ -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
+ -H 'Accept: application/rdf+xml' \
+ "${ADMIN_BASE_URL}"
+
+# Step D: anonymous direct fetch of the same URL. With the fix in place
+# (Client-Cert + non-/static/ path → pass in vcl_recv), Step C didn't store
+# anything, so this reaches the backend anonymously and gets 403.
+
+response=$(curl -k -s -i -H 'Accept: application/rdf+xml' "${ADMIN_BASE_URL}")
+
+status=$(printf '%s\n' "$response" | awk 'NR==1{print $2}' | tr -d '\r')
+link_leak=$(printf '%s\n' "$response" | tr -d '\r' | grep -i '^link:' | grep -c 'acl#agent' || true)
+
+if [ "$status" != "$STATUS_FORBIDDEN" ]; then
+ echo "Step D: expected $STATUS_FORBIDDEN, got $status"
+ exit 1
+fi
+
+if [ "$link_leak" != "0" ]; then
+ echo "Step D: anonymous response leaks acl#agent (cache poisoning)"
+ exit 1
+fi
diff --git a/platform/context.xsl b/platform/context.xsl
index 529c95d5d7..ff80b8a232 100644
--- a/platform/context.xsl
+++ b/platform/context.xsl
@@ -17,7 +17,6 @@ xmlns:orcid="&orcid;"
-
@@ -66,9 +65,6 @@ xmlns:orcid="&orcid;"
-
-
-
diff --git a/platform/entrypoint.sh b/platform/entrypoint.sh
index 9296588602..d5c81dd76c 100755
--- a/platform/entrypoint.sh
+++ b/platform/entrypoint.sh
@@ -1043,10 +1043,6 @@ if [ -n "$PROXY_PORT" ]; then
PROXY_PORT_PARAM="--stringparam ldhc:proxyPort '$PROXY_PORT' "
fi
-if [ -n "$CACHE_MODEL_LOADS" ]; then
- CACHE_MODEL_LOADS_PARAM="--stringparam a:cacheModelLoads '$CACHE_MODEL_LOADS' "
-fi
-
# stylesheet URL must be relative to the base context URL
if [ -n "$STYLESHEET" ]; then
STYLESHEET_PARAM="--stringparam ac:stylesheet '$STYLESHEET' "
@@ -1166,7 +1162,6 @@ fi
transform="xsltproc \
--output conf/Catalina/localhost/ROOT.xml \
- $CACHE_MODEL_LOADS_PARAM \
$STYLESHEET_PARAM \
$CACHE_STYLESHEET_PARAM \
$RESOLVING_UNCACHED_PARAM \
diff --git a/pom.xml b/pom.xml
index 836b779e83..37bc0c6c90 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
com.atomgraph
linkeddatahub
- 5.5.4
+ 5.6.0-SNAPSHOT
${packaging.type}
AtomGraph LinkedDataHub
@@ -93,6 +93,19 @@
org.glassfish.jersey.connectors
jersey-apache-connector
3.1.11
+
+
+
+ commons-logging
+ commons-logging
+
+
+
+
+
+ commons-codec
+ commons-codec
+ 1.22.0
org.glassfish.jersey.media
@@ -112,7 +125,7 @@
com.google.guava
guava
- 31.1-jre
+ 33.6.0-jre
com.atomgraph.etl.csv
@@ -146,14 +159,7 @@
${project.groupId}
twirl
- 1.1.0
-
-
-
- org.slf4j
- slf4j-reload4j
-
-
+ 2.0.0
org.slf4j
@@ -163,13 +169,13 @@
${project.groupId}
client
- 4.3.0
+ 5.0.2
classes
${project.groupId}
client
- 4.3.0
+ 5.0.2
war
@@ -185,24 +191,24 @@
org.jsoup
jsoup
- 1.22.1
+ 1.22.2
org.junit.jupiter
junit-jupiter
- 5.11.4
+ 6.1.0
test
org.mockito
mockito-core
- 5.12.0
+ 5.18.0
test
org.mockito
mockito-junit-jupiter
- 5.12.0
+ 5.18.0
test
diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java
index c9780cee50..2d429ce4f5 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/Application.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java
@@ -16,6 +16,11 @@
*/
package com.atomgraph.linkeddatahub;
+import com.atomgraph.client.util.jena.PrefixGraphRepository;
+import com.atomgraph.client.util.StylesheetResolver;
+import com.atomgraph.linkeddatahub.writer.impl.SameSiteSourceResolver;
+import com.atomgraph.linkeddatahub.server.util.OntologyRepository;
+import org.apache.jena.riot.RDFParser;
import com.atomgraph.linkeddatahub.server.mapper.HttpHostConnectExceptionMapper;
import com.atomgraph.linkeddatahub.server.mapper.InternalURLExceptionMapper;
import com.atomgraph.linkeddatahub.server.mapper.MessagingExceptionMapper;
@@ -26,10 +31,6 @@
import com.atomgraph.linkeddatahub.server.mapper.ForbiddenExceptionMapper;
import com.atomgraph.linkeddatahub.server.mapper.auth.webid.WebIDCertificateExceptionMapper;
import com.atomgraph.client.MediaTypes;
-import com.atomgraph.client.locator.PrefixMapper;
-import org.apache.jena.ontology.OntDocumentManager;
-import org.apache.jena.util.FileManager;
-import org.apache.jena.util.LocationMapper;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.ServletConfig;
import jakarta.ws.rs.core.Context;
@@ -37,8 +38,6 @@
import org.apache.jena.riot.RDFFormat;
import org.apache.jena.riot.RDFWriterRegistry;
import com.atomgraph.client.mapper.ClientErrorExceptionMapper;
-import com.atomgraph.client.util.DataManager;
-import com.atomgraph.client.util.DataManagerImpl;
import com.atomgraph.client.vocabulary.AC;
import com.atomgraph.client.writer.function.UUID;
import com.atomgraph.core.exception.ConfigurationException;
@@ -49,7 +48,7 @@
import com.atomgraph.core.io.UpdateRequestProvider;
import com.atomgraph.core.mapper.BadGatewayExceptionMapper;
import com.atomgraph.core.provider.QueryParamProvider;
-import com.atomgraph.linkeddatahub.writer.factory.DataManagerFactory;
+import com.atomgraph.linkeddatahub.writer.factory.SourceResolverFactory;
import com.atomgraph.server.vocabulary.LDT;
import com.atomgraph.server.mapper.NotFoundExceptionMapper;
import com.atomgraph.core.riot.RDFLanguages;
@@ -67,7 +66,6 @@
import com.atomgraph.linkeddatahub.model.Service;
import com.atomgraph.linkeddatahub.writer.factory.xslt.XsltExecutableSupplier;
import com.atomgraph.linkeddatahub.writer.factory.XsltExecutableSupplierFactory;
-import com.atomgraph.client.util.XsltResolver;
import com.atomgraph.linkeddatahub.client.GraphStoreClient;
import com.atomgraph.linkeddatahub.client.filter.ClientUriRewriteFilter;
import com.atomgraph.linkeddatahub.client.filter.JSONGRDDLFilter;
@@ -135,7 +133,6 @@
import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
import org.apache.jena.enhanced.BuiltinPersonalities;
-import org.apache.jena.ontology.OntModelSpec;
import org.apache.jena.riot.RDFParserRegistry;
import org.slf4j.Logger;
import java.net.URI;
@@ -164,7 +161,7 @@
import javax.net.ssl.TrustManagerFactory;
import jakarta.servlet.ServletContext;
import javax.xml.transform.Source;
-import org.apache.jena.ontology.Ontology;
+import org.apache.jena.ontapi.model.OntModel;
import org.apache.jena.query.Dataset;
import org.apache.jena.query.Query;
import org.apache.jena.query.QueryExecution;
@@ -174,7 +171,6 @@
import org.slf4j.LoggerFactory;
import com.atomgraph.server.mapper.SHACLConstraintViolationExceptionMapper;
import com.atomgraph.server.mapper.SPINConstraintViolationExceptionMapper;
-import com.atomgraph.spinrdf.vocabulary.SP;
import com.apicatalog.jsonld.JsonLdError;
import com.apicatalog.jsonld.JsonLdOptions;
import java.io.FileOutputStream;
@@ -231,7 +227,6 @@
import org.apache.jena.riot.resultset.ResultSetLang;
import org.apache.jena.sparql.graph.GraphReadOnly;
import org.apache.jena.vocabulary.DCTerms;
-import org.apache.jena.vocabulary.LocationMappingVocab;
import org.apache.jena.vocabulary.RDF;
import org.glassfish.hk2.api.TypeLiteral;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
@@ -268,17 +263,16 @@ public class Application extends ResourceConfig
private final ExecutorService importThreadPool;
private final ServletConfig servletConfig;
private final EventBus eventBus = new EventBus();
- private final DataManager dataManager;
- private final Map endUserOntModelSpecs;
+ private final PrefixGraphRepository repository;
+ private final SameSiteSourceResolver resolver;
+ private final Map endUserRepositories;
private final MediaTypes mediaTypes;
private final Client client, externalClient, importClient, noCertClient;
private final Query documentTypeQuery, documentOwnerQuery, aclQuery, ownerAclQuery, webIDQuery, agentQuery, userAccountQuery, ontologyQuery; // no relative URIs
private final Integer maxGetRequestSize;
- private final boolean preemptiveAuth;
private final Processor xsltProc = new Processor(false);
private final XsltCompiler xsltComp;
private final XsltExecutable xsltExec;
- private final OntModelSpec ontModelSpec;
private final boolean cacheStylesheet;
private final boolean resolvingUncached;
private final URI baseURI, uploadRoot;
@@ -322,9 +316,7 @@ public Application(@Context ServletConfig servletConfig) throws URISyntaxExcepti
this(servletConfig,
new MediaTypes(),
servletConfig.getServletContext().getInitParameter(A.maxGetRequestSize.getURI()) != null ? Integer.valueOf(servletConfig.getServletContext().getInitParameter(A.maxGetRequestSize.getURI())) : null,
- servletConfig.getServletContext().getInitParameter(A.cacheModelLoads.getURI()) != null ? Boolean.parseBoolean(servletConfig.getServletContext().getInitParameter(A.cacheModelLoads.getURI())) : true,
- servletConfig.getServletContext().getInitParameter(A.preemptiveAuth.getURI()) != null ? Boolean.parseBoolean(servletConfig.getServletContext().getInitParameter(A.preemptiveAuth.getURI())) : false,
- new PrefixMapper(servletConfig.getServletContext().getInitParameter(AC.prefixMapping.getURI()) != null ? servletConfig.getServletContext().getInitParameter(AC.prefixMapping.getURI()) : null),
+ servletConfig.getServletContext().getInitParameter(AC.prefixMapping.getURI()),
servletConfig.getServletContext().getInitParameter(LDHC.contextDataset.getURI()) != null ? servletConfig.getServletContext().getInitParameter(LDHC.contextDataset.getURI()) : null,
com.atomgraph.client.Application.getSource(servletConfig.getServletContext(), servletConfig.getServletContext().getInitParameter(AC.stylesheet.getURI()) != null ? servletConfig.getServletContext().getInitParameter(AC.stylesheet.getURI()) : null),
servletConfig.getServletContext().getInitParameter(AC.cacheStylesheet.getURI()) != null ? Boolean.parseBoolean(servletConfig.getServletContext().getInitParameter(AC.cacheStylesheet.getURI())) : false,
@@ -387,9 +379,7 @@ public Application(@Context ServletConfig servletConfig) throws URISyntaxExcepti
* @param servletConfig servlet config
* @param mediaTypes supported media types
* @param maxGetRequestSize maximum GET request size
- * @param cacheModelLoads true if model loads should be cached
- * @param preemptiveAuth true if HTTP Basic auth credentials should be sent preemptively
- * @param locationMapper Jena's LocationMapper instance
+ * @param prefixMappingConfig location-mapping configuration source
* @param contextDatasetURIString location of the context dataset
* @param stylesheet stylesheet URI
* @param cacheStylesheet true if stylesheet should be cached
@@ -438,8 +428,8 @@ public Application(@Context ServletConfig servletConfig) throws URISyntaxExcepti
* @param backendProxyEndUserString backend proxy URI for the end-user SPARQL service (endpoint URI rewriting + cache invalidation), or null
*/
public Application(final ServletConfig servletConfig, final MediaTypes mediaTypes,
- final Integer maxGetRequestSize, final boolean cacheModelLoads, final boolean preemptiveAuth,
- final LocationMapper locationMapper, final String contextDatasetURIString,
+ final Integer maxGetRequestSize,
+ final String prefixMappingConfig, final String contextDatasetURIString,
final Source stylesheet, final boolean cacheStylesheet, final boolean resolvingUncached,
final String clientKeyStoreURIString, final String clientKeyStorePassword,
final String secretaryCertAlias,
@@ -579,7 +569,6 @@ public Application(final ServletConfig servletConfig, final MediaTypes mediaType
this.servletConfig = servletConfig;
this.mediaTypes = mediaTypes;
this.maxGetRequestSize = maxGetRequestSize;
- this.preemptiveAuth = preemptiveAuth;
this.cacheStylesheet = cacheStylesheet;
this.resolvingUncached = resolvingUncached;
this.enableLinkedDataProxy = enableLinkedDataProxy;
@@ -687,12 +676,8 @@ public Application(final ServletConfig servletConfig, final MediaTypes mediaType
}
// register plain RDF/XML writer as default
- RDFWriterRegistry.register(Lang.RDFXML, RDFFormat.RDFXML_PLAIN);
+ RDFWriterRegistry.register(Lang.RDFXML, RDFFormat.RDFXML_PLAIN);
- // initialize mapping for locally stored vocabularies
- LocationMapper.setGlobalLocationMapper(locationMapper);
- if (log.isTraceEnabled()) log.trace("LocationMapper.get(): {}", locationMapper);
-
try
{
this.contextDataset = getDataset(servletConfig.getServletContext(), contextDatasetURI);
@@ -752,7 +737,6 @@ public Application(final ServletConfig servletConfig, final MediaTypes mediaType
throw new IllegalStateException(new CertificateException("Secretary certificate with alias " + secretaryCertAlias + " not a valid WebID sertificate (SNA URI is missing)"));
}
- SP.init(BuiltinPersonalities.model);
BuiltinPersonalities.model.add(Authorization.class, AuthorizationImpl.factory);
BuiltinPersonalities.model.add(Agent.class, AgentImpl.factory);
BuiltinPersonalities.model.add(UserAccount.class, UserAccountImpl.factory);
@@ -797,14 +781,16 @@ else if (app.hasProperty(RDF.type, LAPP.EndUserApplication))
serviceIt.close();
}
- // TO-DO: config property for cacheModelLoads
- endUserOntModelSpecs = new HashMap<>();
- dataManager = new DataManagerImpl(locationMapper, new HashMap<>(), GraphStoreClient.create(client, mediaTypes), cacheModelLoads, preemptiveAuth, resolvingUncached);
- ontModelSpec = OntModelSpec.OWL_MEM_RDFS_INF;
- ontModelSpec.setImportModelGetter(dataManager);
- OntDocumentManager.getInstance().setFileManager((FileManager)dataManager);
- OntDocumentManager.getInstance().setCacheModels(true); // need to re-set after changing FileManager
- ontModelSpec.setDocumentManager(OntDocumentManager.getInstance());
+ endUserRepositories = new ConcurrentHashMap<>();
+ // global graph repository: bundled vocabularies/ontologies mapped from the prefix-mapping config
+ repository = new PrefixGraphRepository(GraphStoreClient.create(client, mediaTypes));
+ if (prefixMappingConfig != null)
+ {
+ Model prefixMappingModel = ModelFactory.createDefaultModel();
+ RDFParser.create().source(prefixMappingConfig).streamManager(repository.getStreamManager()).build().parse(prefixMappingModel);
+ repository.processConfig(prefixMappingModel);
+ }
+ resolver = new SameSiteSourceResolver(repository, GraphStoreClient.create(client, mediaTypes), resolvingUncached, baseURI);
if (mailUser != null && mailPassword != null) // enable SMTP authentication
{
@@ -841,19 +827,15 @@ protected PasswordAuthentication getPasswordAuthentication()
xsltProc.registerExtensionFunction(new com.atomgraph.linkeddatahub.writer.function.URLDecode());
xsltProc.registerExtensionFunction(new com.atomgraph.linkeddatahub.writer.function.SendHTTPRequest(xsltProc, client));
- Model mappingModel = locationMapper.toModel();
- ResIterator prefixedMappings = mappingModel.listResourcesWithProperty(LocationMappingVocab.prefix);
try
{
- while (prefixedMappings.hasNext())
+ for (String prefix : getRepository().getPrefixMappings().keySet())
{
- Resource prefixMapping = prefixedMappings.next();
- String prefix = prefixMapping.getRequiredProperty(LocationMappingVocab.prefix).getString();
// register mapped RDF documents in the XSLT processor so that document() returns them cached, throughout multiple transformations
- TreeInfo doc = xsltProc.getUnderlyingConfiguration().buildDocumentTree(dataManager.resolve("", prefix));
+ TreeInfo doc = xsltProc.getUnderlyingConfiguration().buildDocumentTree(getResolver().resolve("", prefix));
xsltProc.getUnderlyingConfiguration().getGlobalDocumentPool().add(doc, prefix);
}
-
+
// register HTTPS URL of translations.rdf so it doesn't have to be requested repeatedly
try (InputStream translations = servletConfig.getServletContext().getResourceAsStream(XSLTWriterBase.TRANSLATIONS_PATH))
{
@@ -866,14 +848,10 @@ protected PasswordAuthentication getPasswordAuthentication()
if (log.isErrorEnabled()) log.error("Error reading mapped RDF document: {}", ex);
throw new IllegalStateException(ex);
}
- finally
- {
- prefixedMappings.close();
- }
xsltComp = xsltProc.newXsltCompiler();
xsltComp.setParameter(new QName("ldh", LDH.base.getNameSpace(), LDH.base.getLocalName()), new XdmAtomicValue(baseURI));
- xsltComp.setURIResolver(new XsltResolver(LocationMapper.get(), new HashMap<>(), GraphStoreClient.create(client, mediaTypes), false, false, true)); // default Xerces parser does not support HTTPS
+ xsltComp.setURIResolver(new StylesheetResolver(client)); // resolves xsl:import to raw stylesheet sources
xsltExec = xsltComp.compile(stylesheet);
}
catch (FileNotFoundException ex)
@@ -940,8 +918,8 @@ public void init()
register(new QueryProvider());
register(new QueryParamProvider());
register(new UpdateRequestProvider());
- register(new ModelXSLTWriter(getXsltExecutable(), getOntModelSpec(), getDataManager(), getMessageDigest())); // writes (X)HTML responses
- register(new ResultSetXSLTWriter(getXsltExecutable(), getOntModelSpec(), getDataManager(), getMessageDigest())); // writes (X)HTML responses
+ register(new ModelXSLTWriter(getXsltExecutable(), getResolver(), getMessageDigest())); // writes (X)HTML responses
+ register(new ResultSetXSLTWriter(getXsltExecutable(), getResolver(), getMessageDigest())); // writes (X)HTML responses
final com.atomgraph.linkeddatahub.Application system = this;
register(new AbstractBinder()
@@ -1019,7 +997,7 @@ protected void configure()
@Override
protected void configure()
{
- bindFactory(OntologyFactory.class).to(new TypeLiteral>() {}).
+ bindFactory(OntologyFactory.class).to(new TypeLiteral>() {}).
in(RequestScoped.class);
}
});
@@ -1028,15 +1006,7 @@ protected void configure()
@Override
protected void configure()
{
- bindFactory(new com.atomgraph.core.factory.DataManagerFactory(getDataManager())).to(com.atomgraph.core.util.jena.DataManager.class);
- }
- });
- register(new AbstractBinder()
- {
- @Override
- protected void configure()
- {
- bindFactory(DataManagerFactory.class).to(com.atomgraph.client.util.DataManager.class).
+ bindFactory(SourceResolverFactory.class).to(com.atomgraph.client.util.RDFSourceResolver.class).
in(RequestScoped.class);
}
});
@@ -1775,43 +1745,62 @@ public EventBus getEventBus()
}
/**
- * Gets Jena's DataManager implementation.
- *
- * @return data manager instance
+ * Returns the global graph repository (bundled vocabularies/ontologies + URI mapping + cache).
+ *
+ * @return graph repository
*/
- public DataManager getDataManager()
+ public PrefixGraphRepository getRepository()
{
- return dataManager;
+ return repository;
}
-
+
/**
- * Returns a map of application URIs to ontology specifications.
- *
- * @return URI to ontology specification map
+ * Returns the global XSLT source resolver.
+ *
+ * @return source resolver
*/
- protected Map getEndUserOntModelSpecs()
+ public SameSiteSourceResolver getResolver()
{
- return endUserOntModelSpecs;
+ return resolver;
}
/**
- * Returns ontology specification for the specified end-user application.
- *
+ * Returns a map of application URIs to ontology repositories.
+ *
+ * @return URI to ontology repository map
+ */
+ protected Map getEndUserRepositories()
+ {
+ return endUserRepositories;
+ }
+
+ /**
+ * Returns the SPARQL-first ontology repository for the specified end-user application.
+ *
* @param app end-user application resource
- * @return ontology specification
+ * @return ontology repository
*/
- public OntModelSpec getOntModelSpec(EndUserApplication app)
+ public OntologyRepository getRepository(EndUserApplication app)
{
- if (!getEndUserOntModelSpecs().containsKey(app.getURI()))
- {
- OntModelSpec appOntModelSpec = new OntModelSpec(OntModelSpec.OWL_MEM_RDFS_INF);
- appOntModelSpec.setDocumentManager(new OntDocumentManager());
- appOntModelSpec.getDocumentManager().setFileManager(new DataManagerImpl(LocationMapper.get(), new HashMap<>(), GraphStoreClient.create(getClient(), getMediaTypes()), true, isPreemptiveAuth(), isResolvingUncached()));
-
- getEndUserOntModelSpecs().put(app.getURI(), appOntModelSpec);
- }
-
- return getEndUserOntModelSpecs().get(app.getURI());
+ return getEndUserRepositories().computeIfAbsent(app.getURI(), uri -> createRepository(app));
+ }
+
+ /**
+ * Creates a fresh ontology repository for an application, seeded with the bundled
+ * vocabulary/ontology mappings from the global repository so that mapped URIs resolve to shipped
+ * files rather than being dereferenced over HTTP.
+ *
+ * @param app end-user application resource
+ * @return ontology repository
+ */
+ public OntologyRepository createRepository(EndUserApplication app)
+ {
+ OntologyRepository appRepository = new OntologyRepository(app, this, GraphStoreClient.create(getClient(), getMediaTypes()), getOntologyQuery());
+ // seed bundled vocabulary/ontology mappings from the global repository
+ getRepository().getLocationMappings().forEach(appRepository::addLocationMapping);
+ getRepository().getPrefixMappings().forEach(appRepository::addPrefixMapping);
+
+ return appRepository;
}
/**
@@ -2011,26 +2000,6 @@ public Integer getMaxGetRequestSize()
return maxGetRequestSize;
}
- /**
- * Returns true if HTTP Basic auth credentials should be sent preemptively.
- *
- * @return true if preemptively
- */
- public boolean isPreemptiveAuth()
- {
- return preemptiveAuth;
- }
-
- /**
- * The default specification of ontology models.
- *
- * @return spec object
- */
- public OntModelSpec getOntModelSpec()
- {
- return ontModelSpec;
- }
-
/**
* Returns Saxon's XSLT compiler.
*
diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/Generate.java b/src/main/java/com/atomgraph/linkeddatahub/resource/Generate.java
index 34b68d81fd..27e2124ffc 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/resource/Generate.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/resource/Generate.java
@@ -42,7 +42,7 @@
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
-import org.apache.jena.ontology.Ontology;
+import org.apache.jena.ontapi.model.OntModel;
import org.apache.jena.query.ParameterizedSparqlString;
import org.apache.jena.query.Query;
import org.apache.jena.query.QueryFactory;
@@ -69,7 +69,7 @@ public class Generate
private final UriInfo uriInfo;
private final MediaTypes mediaTypes;
private final Application application;
- private final Ontology ontology;
+ private final OntModel ontology;
private final Optional agentContext;
private final com.atomgraph.linkeddatahub.Application system;
private final ResourceContext resourceContext;
@@ -88,7 +88,7 @@ public class Generate
*/
@Inject
public Generate(@Context Request request, @Context UriInfo uriInfo, MediaTypes mediaTypes,
- com.atomgraph.linkeddatahub.apps.model.Application application, Optional ontology, Optional agentContext,
+ com.atomgraph.linkeddatahub.apps.model.Application application, Optional ontology, Optional agentContext,
com.atomgraph.linkeddatahub.Application system, @Context ResourceContext resourceContext)
{
if (ontology.isEmpty()) throw new InternalServerErrorException("Ontology is not specified");
@@ -134,7 +134,7 @@ public Response post(Model model)
if (queryRes == null) throw new BadRequestException("Container query string (spin:query) not provided");
// Lookup query in ontology
- Resource queryResource = getOntology().getOntModel().getResource(queryRes.getURI());
+ Resource queryResource = getOntology().getResource(queryRes.getURI());
if (queryResource == null || !queryResource.hasProperty(SP.text))
throw new BadRequestException("Query resource not found in ontology: " + queryRes.getURI());
@@ -265,7 +265,7 @@ public Application getApplication()
*
* @return the ontology
*/
- public Ontology getOntology()
+ public OntModel getOntology()
{
return ontology;
}
diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java b/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java
index 4ccefa319f..5ce68929e5 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java
@@ -31,7 +31,7 @@
import com.atomgraph.core.model.impl.dataset.ServiceImpl;
import com.atomgraph.linkeddatahub.apps.model.Application;
import com.atomgraph.linkeddatahub.apps.model.EndUserApplication;
-import com.atomgraph.linkeddatahub.server.util.OntologyModelGetter;
+import com.atomgraph.linkeddatahub.server.util.OntologyRepository;
import java.net.URI;
import java.util.List;
import java.util.Optional;
@@ -47,7 +47,7 @@
import jakarta.ws.rs.core.SecurityContext;
import jakarta.ws.rs.core.UriInfo;
import org.apache.jena.irix.IRIx;
-import org.apache.jena.ontology.Ontology;
+import org.apache.jena.ontapi.model.OntModel;
import org.apache.jena.query.DatasetFactory;
import org.apache.jena.query.Query;
import org.apache.jena.query.QueryFactory;
@@ -70,7 +70,7 @@ public class Namespace extends com.atomgraph.core.model.impl.SPARQLEndpointImpl
private final URI uri;
private final UriInfo uriInfo;
private final Application application;
- private final Ontology ontology;
+ private final OntModel ontology;
private final com.atomgraph.linkeddatahub.Application system;
/**
@@ -86,10 +86,10 @@ public class Namespace extends com.atomgraph.core.model.impl.SPARQLEndpointImpl
*/
@Inject
public Namespace(@Context Request request, @Context UriInfo uriInfo,
- Application application, Optional ontology, MediaTypes mediaTypes,
+ Application application, Optional ontology, MediaTypes mediaTypes,
@Context SecurityContext securityContext, com.atomgraph.linkeddatahub.Application system)
{
- super(request, new ServiceImpl(DatasetFactory.create(ontology.get().getOntModel()), mediaTypes), mediaTypes);
+ super(request, new ServiceImpl(DatasetFactory.create(ontology.get()), mediaTypes), mediaTypes);
this.uri = uriInfo.getAbsolutePath();
this.uriInfo = uriInfo;
this.application = application;
@@ -128,7 +128,7 @@ public Response get(@QueryParam(QUERY) Query query,
Model instances = ModelFactory.createDefaultModel();
forClasses.stream().
- map(forClass -> Optional.ofNullable(getOntology().getOntModel().getOntClass(checkURI(forClass).toString()))).
+ map(forClass -> Optional.ofNullable(getOntology().getOntClass(checkURI(forClass).toString()))).
flatMap(Optional::stream).
forEach(forClass -> new Constructor().construct(forClass, instances, getApplication().getBase().getURI()));
@@ -139,10 +139,11 @@ public Response get(@QueryParam(QUERY) Query query,
{
// the application ontology MUST use a URI! This is the URI this ontology endpoint is deployed on by the Dispatcher class
String ontologyURI = getApplication().getOntology().getURI();
- if (log.isDebugEnabled()) log.debug("Returning namespace ontology from OntDocumentManager: {}", ontologyURI);
- // not returning the injected in-memory ontology because it has inferences applied to it
- OntologyModelGetter modelGetter = new OntologyModelGetter(getApplication().as(EndUserApplication.class), getSystem(), getSystem().getOntModelSpec(), getSystem().getOntologyQuery());
- return getResponseBuilder(modelGetter.getModel(ontologyURI)).build();
+ if (log.isDebugEnabled()) log.debug("Returning raw namespace ontology: {}", ontologyURI);
+ // not returning the injected in-memory ontology because it has inferences applied to it;
+ // a fresh, mapping-seeded repository serves the raw SPARQL-loaded ontology
+ OntologyRepository repository = getSystem().createRepository(getApplication().as(EndUserApplication.class));
+ return getResponseBuilder(org.apache.jena.rdf.model.ModelFactory.createModelForGraph(repository.get(ontologyURI))).build();
}
else throw new BadRequestException("SPARQL query string not provided");
}
@@ -223,7 +224,7 @@ public Application getApplication()
*
* @return application ontology
*/
- public Ontology getOntology()
+ public OntModel getOntology()
{
return ontology;
}
diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java
index 202f724291..9ce40f1a68 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java
@@ -18,9 +18,8 @@
import com.atomgraph.linkeddatahub.apps.model.AdminApplication;
import com.atomgraph.linkeddatahub.apps.model.EndUserApplication;
-import static com.atomgraph.linkeddatahub.server.filter.request.OntologyFilter.addDocumentModel;
import com.atomgraph.linkeddatahub.server.filter.response.CacheInvalidationFilter;
-import com.atomgraph.linkeddatahub.server.util.OntologyModelGetter;
+import com.atomgraph.linkeddatahub.server.util.OntologyRepository;
import java.net.URI;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
@@ -31,11 +30,7 @@
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
-import org.apache.jena.ontology.OntModel;
-import org.apache.jena.ontology.OntModelSpec;
-import org.apache.jena.rdf.model.Model;
-import org.apache.jena.rdf.model.ModelFactory;
-import org.apache.jena.rdf.model.Resource;
+import com.atomgraph.linkeddatahub.server.filter.request.OntologyFilter;
import org.glassfish.jersey.uri.UriComponent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -82,11 +77,11 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer
if (ontologyURI == null) throw new BadRequestException("Ontology URI not specified");
EndUserApplication endUserApp = getApplication().as(AdminApplication.class).getEndUserApplication(); // we're assuming the current app is admin
- OntModelSpec ontModelSpec = new OntModelSpec(getSystem().getOntModelSpec(endUserApp));
- if (ontModelSpec.getDocumentManager().getFileManager().hasCachedModel(ontologyURI))
+ OntologyRepository repository = getSystem().getRepository(endUserApp);
+ if (repository.isCached(ontologyURI))
{
if (log.isDebugEnabled()) log.debug("Clearing ontology with URI '{}' from memory", ontologyURI);
- ontModelSpec.getDocumentManager().getFileManager().removeCacheModel(ontologyURI);
+ repository.remove(ontologyURI);
URI ontologyDocURI = UriBuilder.fromUri(ontologyURI).fragment(null).build(); // skip fragment from the ontology URI to get its graph URI
// frontend proxy still uses URL-pattern BAN for direct document GETs (until Stage 3 brings xkey tagging to varnish-frontend).
@@ -96,7 +91,6 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer
{
if (log.isDebugEnabled()) log.debug("Purge ontology document with URI '{}' from frontend proxy cache", ontologyDocURI);
ban(frontendProxy, ontologyDocURI.toString(), false);
- xkeyPurge(frontendProxy, ontologyURI);
}
URI adminBackendProxy = getSystem().getServiceContext(getApplication().getService()).getBackendProxy();
if (adminBackendProxy != null)
@@ -116,19 +110,7 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer
}
// !!! we need to reload the ontology model before returning a response, to make sure the next request already gets the new version !!!
- // same logic as in OntologyFilter. TO-DO: encapsulate?
- OntologyModelGetter modelGetter = new OntologyModelGetter(endUserApp, getSystem(), ontModelSpec, getSystem().getOntologyQuery());
- ontModelSpec.setImportModelGetter(modelGetter);
- if (log.isDebugEnabled()) log.debug("Started loading ontology with URI '{}' from the admin dataset", ontologyURI);
- Model baseModel = modelGetter.getModel(ontologyURI);
- OntModel ontModel = ModelFactory.createOntologyModel(ontModelSpec, baseModel);
- // materialize OntModel inferences to avoid invoking rules engine on every request
- OntModel materializedModel = ModelFactory.createOntologyModel(OntModelSpec.OWL_MEM); // no inference
- materializedModel.add(ontModel);
- ontModel.getDocumentManager().addModel(ontologyURI, materializedModel, true); // make immutable and add as OntModel so that imports do not need to be reloaded during retrieval
- // make sure to cache imported models not only by ontology URI but also by document URI
- ontModel.listImportedOntologyURIs(true).forEach((String importURI) -> addDocumentModel(ontModel.getDocumentManager(), importURI));
- if (log.isDebugEnabled()) log.debug("Finished loading ontology with URI '{}' from the admin dataset", ontologyURI);
+ OntologyFilter.loadOntology(repository, ontologyURI);
}
if (referer != null) return Response.seeOther(referer).build();
diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/SignUp.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/SignUp.java
index bb0119ec21..1fd7057e7f 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/SignUp.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/SignUp.java
@@ -72,7 +72,7 @@
import jakarta.ws.rs.core.UriInfo;
import jakarta.ws.rs.ext.Providers;
import static org.apache.jena.datatypes.xsd.XSDDatatype.XSDhexBinary;
-import org.apache.jena.ontology.Ontology;
+import org.apache.jena.ontapi.model.OntModel;
import org.apache.jena.query.ParameterizedSparqlString;
import org.apache.jena.query.Query;
import org.apache.jena.query.ResultSet;
@@ -144,7 +144,7 @@ public class SignUp extends DocumentHierarchyGraphStoreImpl
// TO-DO: move to AuthenticationExceptionMapper and handle as state instead of URI resource?
@Inject
public SignUp(@Context Request request, @Context UriInfo uriInfo, MediaTypes mediaTypes,
- com.atomgraph.linkeddatahub.apps.model.Application application, Optional ontology, Optional service,
+ com.atomgraph.linkeddatahub.apps.model.Application application, Optional ontology, Optional service,
@Context SecurityContext securityContext, Optional agentContext,
@Context Providers providers, com.atomgraph.linkeddatahub.Application system, @Context ServletConfig servletConfig)
{
diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java
index 9e760cd0b1..e178f6dc67 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java
@@ -17,7 +17,6 @@
package com.atomgraph.linkeddatahub.resource.admin.pkg;
import static com.atomgraph.client.MediaType.TEXT_XSL;
-import com.atomgraph.client.util.DataManager;
import com.atomgraph.linkeddatahub.apps.model.AdminApplication;
import com.atomgraph.linkeddatahub.apps.model.EndUserApplication;
import com.atomgraph.linkeddatahub.client.GraphStoreClient;
@@ -60,7 +59,6 @@
import org.apache.jena.ontology.ConversionException;
import org.apache.jena.update.UpdateFactory;
import org.apache.jena.update.UpdateRequest;
-import org.apache.jena.util.FileManager;
import com.atomgraph.linkeddatahub.vocabulary.DH;
import com.atomgraph.linkeddatahub.vocabulary.FOAF;
import com.atomgraph.linkeddatahub.vocabulary.SIOC;
@@ -88,7 +86,6 @@ public class InstallPackage
private final com.atomgraph.linkeddatahub.apps.model.Application application;
private final com.atomgraph.linkeddatahub.Application system;
- private final DataManager dataManager;
private final Optional agentContext;
@Context ServletContext servletContext;
@@ -105,12 +102,10 @@ public class InstallPackage
@Inject
public InstallPackage(com.atomgraph.linkeddatahub.apps.model.Application application,
com.atomgraph.linkeddatahub.Application system,
- DataManager dataManager,
Optional agentContext)
{
this.application = application;
this.system = system;
- this.dataManager = dataManager;
this.agentContext = agentContext;
}
@@ -242,12 +237,12 @@ private com.atomgraph.linkeddatahub.apps.model.Package getPackage(String package
final Model model;
// check if we have the model in the cache first and if yes, return it from there instead making an HTTP request
- if (((FileManager)getDataManager()).hasCachedModel(packageURI) ||
- (getDataManager().isResolvingMapped() && getDataManager().isMapped(packageURI))) // read mapped URIs (such as system ontologies) from a file
+ if (getSystem().getRepository().isCached(packageURI) ||
+ (getSystem().getRepository().isMapped(packageURI))) // read mapped URIs (such as system ontologies) from a file
{
- if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", packageURI, ((FileManager)getDataManager()).hasCachedModel(packageURI));
- if (log.isDebugEnabled()) log.debug("isMapped({}): {}", packageURI, getDataManager().isMapped(packageURI));
- model = getDataManager().loadModel(packageURI);
+ if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", packageURI, getSystem().getRepository().isCached(packageURI));
+ if (log.isDebugEnabled()) log.debug("isMapped({}): {}", packageURI, getSystem().getRepository().isMapped(packageURI));
+ model = ModelFactory.createModelForGraph(getSystem().getRepository().get(packageURI));
}
else
{
@@ -284,12 +279,12 @@ private Model downloadOntology(String uri)
if (log.isDebugEnabled()) log.debug("Downloading ontology from: {}", uri);
// check if we have the model in the cache first and if yes, return it from there instead making an HTTP request
- if (((FileManager)getDataManager()).hasCachedModel(uri) ||
- (getDataManager().isResolvingMapped() && getDataManager().isMapped(uri))) // read mapped URIs (such as system ontologies) from a file
+ if (getSystem().getRepository().isCached(uri) ||
+ (getSystem().getRepository().isMapped(uri))) // read mapped URIs (such as system ontologies) from a file
{
- if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", uri, ((FileManager)getDataManager()).hasCachedModel(uri));
- if (log.isDebugEnabled()) log.debug("isMapped({}): {}", uri, getDataManager().isMapped(uri));
- return getDataManager().loadModel(uri);
+ if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", uri, getSystem().getRepository().isCached(uri));
+ if (log.isDebugEnabled()) log.debug("isMapped({}): {}", uri, getSystem().getRepository().isMapped(uri));
+ return ModelFactory.createModelForGraph(getSystem().getRepository().get(uri));
}
else
{
@@ -522,15 +517,6 @@ public ServletContext getServletContext()
return servletContext;
}
- /**
- * Returns RDF data manager.
- *
- * @return RDF data manager
- */
- public DataManager getDataManager()
- {
- return dataManager;
- }
/**
* Returns JAX-RS resource context.
diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/UninstallPackage.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/UninstallPackage.java
index fb0cb5f3c7..28d37ca9fa 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/UninstallPackage.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/UninstallPackage.java
@@ -16,7 +16,6 @@
*/
package com.atomgraph.linkeddatahub.resource.admin.pkg;
-import com.atomgraph.client.util.DataManager;
import com.atomgraph.linkeddatahub.apps.model.AdminApplication;
import com.atomgraph.linkeddatahub.apps.model.EndUserApplication;
import com.atomgraph.linkeddatahub.client.GraphStoreClient;
@@ -39,10 +38,10 @@
import jakarta.ws.rs.core.UriBuilder;
import org.apache.commons.codec.binary.Hex;
import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.rdf.model.Resource;
import org.apache.jena.update.UpdateFactory;
import org.apache.jena.update.UpdateRequest;
-import org.apache.jena.util.FileManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
@@ -73,7 +72,6 @@ public class UninstallPackage
private final com.atomgraph.linkeddatahub.apps.model.Application application;
private final com.atomgraph.linkeddatahub.Application system;
- private final DataManager dataManager;
private final Optional agentContext;
@Context ServletContext servletContext;
@@ -90,12 +88,10 @@ public class UninstallPackage
@Inject
public UninstallPackage(com.atomgraph.linkeddatahub.apps.model.Application application,
com.atomgraph.linkeddatahub.Application system,
- DataManager dataManager,
Optional agentContext)
{
this.application = application;
this.system = system;
- this.dataManager = dataManager;
this.agentContext = agentContext;
}
@@ -317,12 +313,12 @@ private com.atomgraph.linkeddatahub.apps.model.Package getPackage(String package
final Model model;
// check if we have the model in the cache first and if yes, return it from there instead making an HTTP request
- if (((FileManager)getDataManager()).hasCachedModel(packageURI) ||
- (getDataManager().isResolvingMapped() && getDataManager().isMapped(packageURI))) // read mapped URIs (such as system ontologies) from a file
+ if (getSystem().getRepository().isCached(packageURI) ||
+ (getSystem().getRepository().isMapped(packageURI))) // read mapped URIs (such as system ontologies) from a file
{
- if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", packageURI, ((FileManager)getDataManager()).hasCachedModel(packageURI));
- if (log.isDebugEnabled()) log.debug("isMapped({}): {}", packageURI, getDataManager().isMapped(packageURI));
- model = getDataManager().loadModel(packageURI);
+ if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", packageURI, getSystem().getRepository().isCached(packageURI));
+ if (log.isDebugEnabled()) log.debug("isMapped({}): {}", packageURI, getSystem().getRepository().isMapped(packageURI));
+ model = ModelFactory.createModelForGraph(getSystem().getRepository().get(packageURI));
}
else
{
@@ -350,15 +346,6 @@ public com.atomgraph.linkeddatahub.Application getSystem()
return system;
}
- /**
- * Returns RDF data manager.
- *
- * @return RDF data manager
- */
- public DataManager getDataManager()
- {
- return dataManager;
- }
/**
* Returns JAX-RS resource context.
diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/event/AuthorizationCreated.java b/src/main/java/com/atomgraph/linkeddatahub/server/event/AuthorizationCreated.java
index 7353e02978..3b02124d24 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/server/event/AuthorizationCreated.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/server/event/AuthorizationCreated.java
@@ -16,7 +16,6 @@
*/
package com.atomgraph.linkeddatahub.server.event;
-import com.atomgraph.core.util.jena.DataManager;
import com.atomgraph.linkeddatahub.apps.model.Application;
import com.atomgraph.linkeddatahub.client.GraphStoreClient;
import org.apache.jena.rdf.model.Resource;
diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/factory/OntologyFactory.java b/src/main/java/com/atomgraph/linkeddatahub/server/factory/OntologyFactory.java
index 39fe494b6f..8fc56c6a24 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/server/factory/OntologyFactory.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/server/factory/OntologyFactory.java
@@ -20,7 +20,7 @@
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.ext.Provider;
-import org.apache.jena.ontology.Ontology;
+import org.apache.jena.ontapi.model.OntModel;
import org.apache.jena.vocabulary.OWL;
import org.glassfish.hk2.api.Factory;
import org.glassfish.hk2.api.ServiceLocator;
@@ -31,19 +31,19 @@
* @author Martynas Jusevičius {@literal }
*/
@Provider
-public class OntologyFactory implements Factory>
+public class OntologyFactory implements Factory>
{
@Context private ServiceLocator serviceLocator;
@Override
- public Optional provide()
+ public Optional provide()
{
return getOntology();
}
@Override
- public void dispose(Optional t)
+ public void dispose(Optional t)
{
}
@@ -52,9 +52,9 @@ public void dispose(Optional t)
*
* @return ontology
*/
- public Optional getOntology()
+ public Optional getOntology()
{
- return (Optional)getContainerRequestContext().getProperty(OWL.Ontology.getURI());
+ return (Optional)getContainerRequestContext().getProperty(OWL.Ontology.getURI());
}
/**
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 03ef836621..e9bd7af715 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
@@ -18,27 +18,28 @@
import com.atomgraph.linkeddatahub.apps.model.Application;
import com.atomgraph.linkeddatahub.apps.model.EndUserApplication;
+import com.atomgraph.client.util.jena.PrefixGraphRepository;
import com.atomgraph.linkeddatahub.vocabulary.LAPP;
import com.atomgraph.server.exception.OntologyException;
-import com.atomgraph.linkeddatahub.server.util.OntologyModelGetter;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
+import java.util.HashSet;
import java.util.Optional;
+import java.util.Set;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
-import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.container.PreMatching;
-import org.apache.jena.ontology.OntDocumentManager;
-import org.apache.jena.ontology.OntModel;
-import org.apache.jena.ontology.OntModelSpec;
-import org.apache.jena.ontology.Ontology;
+import org.apache.jena.ontapi.OntModelFactory;
+import org.apache.jena.ontapi.OntSpecification;
+import org.apache.jena.ontapi.model.OntModel;
import org.apache.jena.rdf.model.Model;
import org.apache.jena.rdf.model.ModelFactory;
-import org.apache.jena.util.FileManager;
import org.apache.jena.vocabulary.OWL;
+import org.apache.jena.vocabulary.RDF;
+import org.apache.jena.vocabulary.RDFS;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -100,7 +101,7 @@ public void filter(ContainerRequestContext crc) throws IOException
* @param crc request context
* @return optional ontology
*/
- public Optional getOntology(ContainerRequestContext crc)
+ public Optional getOntology(ContainerRequestContext crc)
{
Optional appOpt = getApplication(crc);
@@ -115,99 +116,112 @@ public Optional getOntology(ContainerRequestContext crc)
return Optional.empty();
}
}
-
+
/**
* Gets ontology of the specified application.
- *
+ *
* @param app application resource
- * @return ontology resource
+ * @return ontology model
*/
- public Ontology getOntology(Application app)
+ public OntModel getOntology(Application app)
{
if (app.getOntology() == null) return null;
return getOntology(app, app.getOntology().getURI());
}
-
+
/**
- * Loads ontology using the specified ontology URI.
- *
+ * Loads the ontology model for the specified ontology URI, building its owl:imports closure with
+ * RDFS inference and materializing the inferences into the repository cache.
+ *
* @param app application resource
* @param uri ontology URI
- * @return ontology resource
+ * @return ontology model
*/
- public Ontology getOntology(Application app, String uri)
+ public OntModel getOntology(Application app, String uri)
{
- if (app == null) throw new IllegalArgumentException("Application string cannot be null");
- if (uri == null) throw new IllegalArgumentException("Ontology URI string cannot be null");
+ if (app == null) throw new IllegalArgumentException("Application cannot be null");
+ if (uri == null) throw new IllegalArgumentException("Ontology URI cannot be null");
- final OntModelSpec ontModelSpec;
- if (app.canAs(EndUserApplication.class))
+ final PrefixGraphRepository repository = app.canAs(EndUserApplication.class) ?
+ getSystem().getRepository(app.as(EndUserApplication.class)) : getSystem().getRepository();
+
+ // only build the materialized model if the ontology is not already cached; the double check under the
+ // repository lock ensures a single thread materializes it (loadOntology is a compound load + inference +
+ // put, not atomic), so concurrent cold requests don't duplicate the work or race each other's writes
+ if (!repository.isCached(uri))
{
- ontModelSpec = new OntModelSpec(getSystem().getOntModelSpec(app.as(EndUserApplication.class)));
- // only create InfModel if ontology is not already cached
- if (!ontModelSpec.getDocumentManager().getFileManager().hasCachedModel(uri))
+ synchronized (repository)
{
- OntologyModelGetter modelGetter = new OntologyModelGetter(app.as(EndUserApplication.class), getSystem(), ontModelSpec, getSystem().getOntologyQuery());
- ontModelSpec.setImportModelGetter(modelGetter);
- if (log.isDebugEnabled()) log.debug("Started loading ontology with URI '{}' from the admin dataset", uri);
- Model baseModel = modelGetter.getModel(uri);
- OntModel ontModel = ModelFactory.createOntologyModel(ontModelSpec, baseModel);
- // materialize OntModel inferences to avoid invoking rules engine on every request
- OntModel materializedModel = ModelFactory.createOntologyModel(OntModelSpec.OWL_MEM); // no inference
- materializedModel.add(ontModel);
- ontModel.getDocumentManager().addModel(uri, materializedModel, true); // make immutable and add as OntModel so that imports do not need to be reloaded during retrieval
- // make sure to cache imported models not only by ontology URI but also by document URI
- ontModel.listImportedOntologyURIs(true).forEach((String importURI) -> addDocumentModel(ontModel.getDocumentManager(), importURI));
- if (log.isDebugEnabled()) log.debug("Finished loading ontology with URI '{}' from the admin dataset", uri);
+ if (!repository.isCached(uri)) loadOntology(repository, uri);
}
}
- else
+
+ return OntModelFactory.createModel(repository.get(uri), OntSpecification.OWL2_FULL_MEM);
+ }
+
+ /**
+ * Builds and caches the materialized ontology model. Assembles the owl:imports closure into a single
+ * graph (so ontapi never manages a union-graph hierarchy over the shared repository), applies RDFS
+ * inference over the flattened closure, and materializes the inferences into the repository cache so
+ * the rules engine is not invoked on every request.
+ *
+ * @param repository graph repository
+ * @param uri ontology URI
+ */
+ public static void loadOntology(PrefixGraphRepository repository, String uri)
+ {
+ if (log.isDebugEnabled()) log.debug("Started loading ontology with URI '{}'", uri);
+ Model union = ModelFactory.createDefaultModel();
+ Set closure = new HashSet<>();
+ loadClosure(repository, uri, union, closure);
+ OntModel inferred = OntModelFactory.createModel(union.getGraph(), OntSpecification.OWL2_FULL_MEM_RDFS_INF);
+ OntModel materialized = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM);
+ materialized.add(inferred);
+ // promote rdfs:Class to owl:Class so OWL2 profiles recognise third-party vocab terms (e.g. sp:Describe in sp.ttl)
+ inferred.listSubjectsWithProperty(RDF.type, RDFS.Class).forEach(r -> materialized.add(r, RDF.type, OWL.Class));
+ repository.put(uri, materialized.getGraph());
+ // cache imported graphs under their fragment-stripped document URIs too
+ closure.stream().filter(closureURI -> !closureURI.equals(uri)).forEach(importURI -> addDocumentModel(repository, importURI));
+ if (log.isDebugEnabled()) log.debug("Finished loading ontology with URI '{}'", uri);
+ }
+
+ /**
+ * Recursively loads the transitive owl:imports closure of an ontology into a single union model,
+ * fetching each graph via the repository (SPARQL-first / bundled mappings).
+ *
+ * @param repository graph repository
+ * @param uri ontology URI
+ * @param union accumulator model
+ * @param seen accumulator of visited URIs (prevents cycles)
+ */
+ public static void loadClosure(PrefixGraphRepository repository, String uri, Model union, Set seen)
+ {
+ if (!seen.add(uri)) return;
+ Model model = ModelFactory.createModelForGraph(repository.get(uri));
+ union.add(model);
+ model.listObjectsOfProperty(OWL.imports).toList().forEach(imp ->
{
- ontModelSpec = new OntModelSpec(getSystem().getOntModelSpec());
- FileManager fileManager = ontModelSpec.getDocumentManager().getFileManager();
- if (!fileManager.hasCachedModel(uri))
- {
- try
- {
- URI ontologyURI = URI.create(uri);
- // remove fragment and normalize
- URI ontDocURI = new URI(ontologyURI.getScheme(), ontologyURI.getSchemeSpecificPart(), null).normalize();
- Model baseModel = fileManager.loadModel(ontDocURI.toString());
- OntModel ontModel = ModelFactory.createOntologyModel(ontModelSpec, baseModel);
- ontModel.getDocumentManager().addModel(uri, ontModel, true);
- }
- catch (URISyntaxException ex)
- {
- if (log.isErrorEnabled()) log.error("Ontology URI syntax error: {}", ex.getInput());
- throw new InternalServerErrorException(ex);
- }
- }
- }
- return ontModelSpec.getDocumentManager().getOntology(uri, ontModelSpec).getOntology(uri); // reloads the imports using ModelGetter. TO-DO: optimize?
+ if (imp.isURIResource()) loadClosure(repository, imp.asResource().getURI(), union, seen);
+ });
}
/**
- * Extracts document URI from ontology import URI and uses it as a secondary cache key.
- *
- * @param odm document manager
+ * Caches an imported graph under its fragment-stripped document URI as a secondary cache key.
+ *
+ * @param repository graph repository
* @param importURI ontology URI
*/
- public static void addDocumentModel(OntDocumentManager odm, String importURI)
+ public static void addDocumentModel(PrefixGraphRepository repository, String importURI)
{
try
{
URI ontologyURI = URI.create(importURI);
// remove fragment and normalize
URI docURI = new URI(ontologyURI.getScheme(), ontologyURI.getSchemeSpecificPart(), null).normalize();
- String mappedURI = odm.getFileManager().mapURI(docURI.toString());
- // only cache import document URI if it's not already cached or mapped
- if (!odm.getFileManager().hasCachedModel(docURI.toString()) && mappedURI.equals(docURI.toString()))
- {
- Model importModel = odm.getModel(importURI);
- if (importModel == null) throw new IllegalArgumentException("Import model is not cached");
- odm.addModel(docURI.toString(), importModel, true);
- }
+ // only cache the document URI if it is not already cached or mapped to a different location
+ if (!repository.isCached(docURI.toString()) && repository.resolve(docURI.toString()).equals(docURI.toString()))
+ repository.put(docURI.toString(), repository.get(importURI));
}
catch (URISyntaxException ex)
{
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 e42e43a937..9166b28ea9 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
@@ -22,7 +22,7 @@
import com.atomgraph.core.exception.BadGatewayException;
import com.atomgraph.core.util.ModelUtils;
import com.atomgraph.linkeddatahub.apps.model.Dataset;
-import org.apache.jena.ontology.Ontology;
+import org.apache.jena.ontapi.model.OntModel;
import com.atomgraph.linkeddatahub.client.GraphStoreClient;
import com.atomgraph.linkeddatahub.client.filter.auth.IDTokenDelegationFilter;
import com.atomgraph.linkeddatahub.client.filter.auth.WebIDDelegationFilter;
@@ -38,8 +38,8 @@
import java.util.List;
import java.util.Optional;
import java.util.Set;
+import org.apache.jena.query.ParameterizedSparqlString;
import org.apache.jena.query.QueryExecution;
-import org.apache.jena.query.QueryFactory;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.ws.rs.NotAllowedException;
@@ -50,6 +50,7 @@
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
+import jakarta.ws.rs.HttpMethod;
import jakarta.ws.rs.container.PreMatching;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.EntityTag;
@@ -132,7 +133,7 @@ public class ProxyRequestFilter implements ContainerRequestFilter
"Age");
@Inject com.atomgraph.linkeddatahub.Application system;
- @Inject jakarta.inject.Provider> ontology;
+ @Inject jakarta.inject.Provider> ontology;
@Inject MediaTypes mediaTypes;
@Context Request request;
@@ -177,10 +178,10 @@ public void filter(ContainerRequestContext requestContext) throws IOException
|| "HEAD".equalsIgnoreCase(requestContext.getMethod());
// serve mapped URIs (e.g. system ontologies) directly from the DataManager cache
- if (isSafeMethod && getSystem().getDataManager().isMapped(targetURI.toString()))
+ if (isSafeMethod && getSystem().getRepository().isMapped(targetURI.toString()))
{
if (log.isDebugEnabled()) log.debug("Serving mapped URI from DataManager cache: {}", targetURI);
- Model model = getSystem().getDataManager().loadModel(targetURI.toString());
+ Model model = org.apache.jena.rdf.model.ModelFactory.createModelForGraph(getSystem().getRepository().get(targetURI.toString()));
requestContext.abortWith(getResponse(model, Response.Status.OK));
return;
}
@@ -191,9 +192,10 @@ public void filter(ContainerRequestContext requestContext) throws IOException
// ?term where STR(?term) starts with "#")
if (isSafeMethod && getOntology().isPresent())
{
- String describeQueryStr = "DESCRIBE <" + targetURI + "> ?term " +
- "WHERE { ?term ?p ?o FILTER(STRSTARTS(STR(?term), CONCAT(STR(<" + targetURI + ">), \"#\"))) }";
- try (QueryExecution qe = QueryExecution.create(QueryFactory.create(describeQueryStr), getOntology().get().getOntModel()))
+ ParameterizedSparqlString pss = new ParameterizedSparqlString(
+ "DESCRIBE ?doc ?term WHERE { ?term ?p ?o FILTER(STRSTARTS(STR(?term), CONCAT(STR(?doc), \"#\"))) }");
+ pss.setIri("doc", targetURI.toString());
+ try (QueryExecution qe = QueryExecution.create(pss.asQuery(), getOntology().get()))
{
Model description = qe.execDescribe();
if (!description.isEmpty())
@@ -240,7 +242,7 @@ else if (agentContext instanceof IDTokenSecurityContext idTokenSecurityContext)
try (clientResponse)
{
- requestContext.abortWith(getResponse(clientResponse, targetURI));
+ requestContext.abortWith(getResponse(clientResponse, targetURI, requestContext.getMethod()));
}
}
catch (MessageBodyProviderNotFoundException ex)
@@ -274,9 +276,7 @@ protected Optional resolveTargetURI(ContainerRequestContext requestContext)
if (proxyTarget != null) return Optional.of(proxyTarget);
// Case 2: lapp:Dataset proxy
- @SuppressWarnings("unchecked")
- Optional datasetOpt =
- (Optional) requestContext.getProperty(LAPP.Dataset.getURI());
+ Optional datasetOpt = (Optional) requestContext.getProperty(LAPP.Dataset.getURI());
if (datasetOpt != null && datasetOpt.isPresent())
{
URI proxied = datasetOpt.get().getProxied(requestContext.getUriInfo().getAbsolutePath());
@@ -320,10 +320,23 @@ protected Optional resolveTargetURI(ContainerRequestContext requestContext)
*
* @param clientResponse response from the proxy target
* @param targetURI upstream URI (used as the parse base URI hint for {@code ModelProvider})
+ * @param method HTTP method
* @return JAX-RS response to return to the original caller
*/
- protected Response getResponse(Response clientResponse, URI targetURI)
+ protected Response getResponse(Response clientResponse, URI targetURI, String method)
{
+ // HEAD responses have no body by HTTP semantics. Routing them through
+ // the typed branches below would parse an empty entity into an empty
+ // Model/ResultSet, then re-stamp ETag/Last-Modified off that empty
+ // value — producing validators that disagree with the upstream GET.
+ // Forward the upstream headers (including ETag) verbatim instead.
+ if (HttpMethod.HEAD.equalsIgnoreCase(method))
+ {
+ Response.ResponseBuilder rb = Response.status(clientResponse.getStatus());
+ if (clientResponse.getMediaType() != null) rb.type(clientResponse.getMediaType());
+ return overlayHeaders(rb.build(), clientResponse, true);
+ }
+
if (clientResponse.getMediaType() == null)
{
Response.ResponseBuilder rb = Response.status(clientResponse.getStatus());
@@ -473,7 +486,7 @@ public com.atomgraph.linkeddatahub.Application getSystem()
*
* @return optional ontology
*/
- public Optional getOntology()
+ public Optional getOntology()
{
return ontology.get();
}
diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/WebIDFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/WebIDFilter.java
index b42e1fbe49..7dbfa8baf8 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/WebIDFilter.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/WebIDFilter.java
@@ -39,7 +39,6 @@
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Priority;
import jakarta.servlet.http.HttpServletRequest;
-import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.client.Client;
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 206228401f..018bca6c29 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/server/io/ValidatingModelProvider.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/server/io/ValidatingModelProvider.java
@@ -240,7 +240,7 @@ public Resource processRead(Resource resource) // this logic really belongs in a
if (getApplication().isPresent() && getApplication().get().canAs(AdminApplication.class) && resource.hasProperty(RDF.type, OWL.Ontology))
{
// clear cached OntModel if ontology is updated. TO-DO: send event instead
- getSystem().getOntModelSpec().getDocumentManager().getFileManager().removeCacheModel(resource.getURI());
+ getSystem().getRepository().remove(resource.getURI());
}
if (getApplication().isPresent() && resource.hasProperty(RDF.type, ACL.Authorization))
diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/DocumentHierarchyGraphStoreImpl.java b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/DocumentHierarchyGraphStoreImpl.java
index 0b8b65674f..f82fc0c66f 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/DocumentHierarchyGraphStoreImpl.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/DocumentHierarchyGraphStoreImpl.java
@@ -89,7 +89,7 @@
import org.apache.commons.lang3.StringUtils;
import org.apache.jena.atlas.RuntimeIOException;
import org.apache.jena.datatypes.xsd.XSDDateTime;
-import org.apache.jena.ontology.Ontology;
+import org.apache.jena.ontapi.model.OntModel;
import org.apache.jena.query.Dataset;
import org.apache.jena.query.DatasetFactory;
import org.apache.jena.rdf.model.Model;
@@ -132,7 +132,7 @@ public class DocumentHierarchyGraphStoreImpl extends com.atomgraph.core.model.im
public static final String UPLOADS_PATH = "uploads";
private final com.atomgraph.linkeddatahub.apps.model.Application application;
- private final Ontology ontology;
+ private final OntModel ontology;
private final Service service;
private final Providers providers;
private final com.atomgraph.linkeddatahub.Application system;
@@ -160,7 +160,7 @@ public class DocumentHierarchyGraphStoreImpl extends com.atomgraph.core.model.im
*/
@Inject
public DocumentHierarchyGraphStoreImpl(@Context Request request, @Context UriInfo uriInfo, MediaTypes mediaTypes,
- com.atomgraph.linkeddatahub.apps.model.Application application, Optional